forest_admin_datasource_pylon 1.41.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/README.md +179 -0
  4. data/Rakefile +6 -0
  5. data/forest_admin_datasource_pylon.gemspec +36 -0
  6. data/lib/forest_admin_datasource_pylon/client/writes.rb +88 -0
  7. data/lib/forest_admin_datasource_pylon/client.rb +436 -0
  8. data/lib/forest_admin_datasource_pylon/collections/account/api_filters.rb +43 -0
  9. data/lib/forest_admin_datasource_pylon/collections/account/schema_definition.rb +91 -0
  10. data/lib/forest_admin_datasource_pylon/collections/account/serializer.rb +21 -0
  11. data/lib/forest_admin_datasource_pylon/collections/account.rb +47 -0
  12. data/lib/forest_admin_datasource_pylon/collections/base_collection.rb +563 -0
  13. data/lib/forest_admin_datasource_pylon/collections/contact/api_filters.rb +38 -0
  14. data/lib/forest_admin_datasource_pylon/collections/contact/schema_definition.rb +93 -0
  15. data/lib/forest_admin_datasource_pylon/collections/contact/serializer.rb +20 -0
  16. data/lib/forest_admin_datasource_pylon/collections/contact.rb +45 -0
  17. data/lib/forest_admin_datasource_pylon/collections/cursor_collection.rb +131 -0
  18. data/lib/forest_admin_datasource_pylon/collections/fetch_all_collection.rb +192 -0
  19. data/lib/forest_admin_datasource_pylon/collections/issue/api_filters.rb +41 -0
  20. data/lib/forest_admin_datasource_pylon/collections/issue/id_lookup_reader.rb +88 -0
  21. data/lib/forest_admin_datasource_pylon/collections/issue/messages_embedder.rb +89 -0
  22. data/lib/forest_admin_datasource_pylon/collections/issue/schema_definition.rb +122 -0
  23. data/lib/forest_admin_datasource_pylon/collections/issue/serializer.rb +26 -0
  24. data/lib/forest_admin_datasource_pylon/collections/issue.rb +128 -0
  25. data/lib/forest_admin_datasource_pylon/collections/record_serialization.rb +84 -0
  26. data/lib/forest_admin_datasource_pylon/collections/relation_embedder.rb +101 -0
  27. data/lib/forest_admin_datasource_pylon/collections/team.rb +49 -0
  28. data/lib/forest_admin_datasource_pylon/collections/user.rb +69 -0
  29. data/lib/forest_admin_datasource_pylon/collections/writes.rb +417 -0
  30. data/lib/forest_admin_datasource_pylon/configuration.rb +84 -0
  31. data/lib/forest_admin_datasource_pylon/datasource.rb +53 -0
  32. data/lib/forest_admin_datasource_pylon/issue_enums.rb +20 -0
  33. data/lib/forest_admin_datasource_pylon/pagination/cursor_walker.rb +103 -0
  34. data/lib/forest_admin_datasource_pylon/plugins/close_issue/messages.rb +62 -0
  35. data/lib/forest_admin_datasource_pylon/plugins/close_issue.rb +141 -0
  36. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/form_builder.rb +173 -0
  37. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification/payload.rb +72 -0
  38. data/lib/forest_admin_datasource_pylon/plugins/create_issue_with_notification.rb +175 -0
  39. data/lib/forest_admin_datasource_pylon/plugins/issue_targets.rb +51 -0
  40. data/lib/forest_admin_datasource_pylon/query/condition_tree_translator.rb +151 -0
  41. data/lib/forest_admin_datasource_pylon/query/filter_value.rb +135 -0
  42. data/lib/forest_admin_datasource_pylon/query/operator_maps.rb +108 -0
  43. data/lib/forest_admin_datasource_pylon/rate_limiter.rb +139 -0
  44. data/lib/forest_admin_datasource_pylon/rate_limits.rb +96 -0
  45. data/lib/forest_admin_datasource_pylon/retry_policy.rb +86 -0
  46. data/lib/forest_admin_datasource_pylon/schema/custom_fields_introspector.rb +203 -0
  47. data/lib/forest_admin_datasource_pylon/throttle.rb +21 -0
  48. data/lib/forest_admin_datasource_pylon/version.rb +3 -0
  49. data/lib/forest_admin_datasource_pylon.rb +70 -0
  50. metadata +152 -0
@@ -0,0 +1,436 @@
1
+ module ForestAdminDatasourcePylon
2
+ # Long by line count only: the public surface is one explicit method per Pylon
3
+ # endpoint, each delegating to the shared helpers below.
4
+ class Client # rubocop:disable Metrics/ClassLength
5
+ include Writes
6
+
7
+ MAX_SEARCH_LIMIT = 1000
8
+
9
+ # Bounds `collect_pages`, which asks for a whole dataset rather than a
10
+ # window: the endpoints it reads answer in one response, so reaching this
11
+ # many pages means the API started paginating on its own and the walk is
12
+ # spending more of the per-minute budget than the answer is worth.
13
+ #
14
+ # What reaching it costs is the caller's to say: a conversation thread and a
15
+ # custom-field list are truncated with a warning, where a collection whose
16
+ # answer is only correct whole is refused. See `refuse_past_cap`.
17
+ MAX_COLLECTED_PAGES = 10
18
+
19
+ # `next_cursor` is nil as soon as Pylon stops advertising a next page, so
20
+ # callers never have to know how the absence is spelled on the wire.
21
+ SearchPage = Struct.new(:records, :next_cursor, keyword_init: true)
22
+
23
+ def initialize(configuration)
24
+ @configuration = configuration
25
+ end
26
+
27
+ # Health check: Pylon returns the details of the organization owning the
28
+ # token, which is enough to prove the credentials are usable.
29
+ def me
30
+ must_succeed('me') { extract_data(connection.get('me').body) }
31
+ end
32
+
33
+ # POST /issues/search accepts an empty body and then returns the most recent
34
+ # issues, ordered by `created_at` descending.
35
+ def search_issues(limit:, cursor: nil, filter: nil, search_text: nil)
36
+ search_resource('issues/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
37
+ end
38
+
39
+ # Accepts either the UUID or the issue number.
40
+ def fetch_issue(id)
41
+ fetch_resource('issues', id)
42
+ end
43
+
44
+ # The whole conversation of an issue, oldest message first, or nil when the
45
+ # thread could not be read.
46
+ #
47
+ # `limit` is left out on purpose: Pylon then answers with every message in a
48
+ # single response. Asking for a page would hand back the OLDEST messages and
49
+ # cut the most recent ones off, which is the half of a conversation nobody
50
+ # opens a ticket to read.
51
+ #
52
+ # The cursor is still followed, defensively: Pylon paginates this endpoint
53
+ # when asked to, so a future default page size stays handled rather than
54
+ # silently truncating the thread.
55
+ def fetch_issue_messages(issue_id)
56
+ path = "issues/#{Faraday::Utils.escape(issue_id)}/messages"
57
+
58
+ best_effort("fetch_issue_messages(#{issue_id})", default: nil) { must_succeed(path) { collect_pages(path) } }
59
+ end
60
+
61
+ def search_accounts(limit:, cursor: nil, filter: nil, search_text: nil)
62
+ search_resource('accounts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
63
+ end
64
+
65
+ def list_accounts(limit:, cursor: nil)
66
+ list_resource('accounts', limit: limit, cursor: cursor)
67
+ end
68
+
69
+ # Accepts either the Pylon UUID or the account's external id.
70
+ def fetch_account(id)
71
+ fetch_resource('accounts', id)
72
+ end
73
+
74
+ def search_contacts(limit:, cursor: nil, filter: nil, search_text: nil)
75
+ search_resource('contacts/search', limit: limit, cursor: cursor, filter: filter, search_text: search_text)
76
+ end
77
+
78
+ # GET /contacts is paginated exactly like GET /accounts even though the
79
+ # OpenAPI spec forgets to document its query parameters.
80
+ def list_contacts(limit:, cursor: nil)
81
+ list_resource('contacts', limit: limit, cursor: cursor)
82
+ end
83
+
84
+ def fetch_contact(id)
85
+ fetch_resource('contacts', id)
86
+ end
87
+
88
+ # GET /users is unpaginated. Deactivated agents are included by default so
89
+ # that assignees of older issues stay resolvable.
90
+ def fetch_users(include_deactivated: true)
91
+ fetch_all('users', 'include_deactivated' => include_deactivated)
92
+ end
93
+
94
+ def fetch_user(id)
95
+ fetch_resource('users', id)
96
+ end
97
+
98
+ # GET /teams is unpaginated and takes no parameter.
99
+ def fetch_teams
100
+ fetch_all('teams')
101
+ end
102
+
103
+ def fetch_team(id)
104
+ fetch_resource('teams', id)
105
+ end
106
+
107
+ # The custom-field definitions of one object type. `object_type` is
108
+ # mandatory on this endpoint, so a schema spanning several collections costs
109
+ # one call per collection rather than one call in total.
110
+ #
111
+ # Degrades to nil: this is read while the agent boots, and a token missing
112
+ # the permission — or a Pylon that happens to be down right then — has to
113
+ # cost the operator the custom columns, not the whole datasource.
114
+ #
115
+ # nil rather than an empty list so the caller can tell a failure from an
116
+ # organization that defined no custom field, the two costing very different
117
+ # things: the second says nothing about the next object type, the first says
118
+ # it will almost certainly fail the same way.
119
+ #
120
+ # Which is why it goes through `boot_connection`: a call declared best-effort
121
+ # has no business holding the boot for the minutes the resilient policy is
122
+ # willing to spend waiting a 429 out. The bound is per request, and the walk
123
+ # is allowed the same pages as any other — one is what this endpoint answers
124
+ # with, having a handful of definitions to return per object type.
125
+ def fetch_custom_fields(object_type)
126
+ params = { 'object_type' => object_type }
127
+
128
+ best_effort("fetch_custom_fields(#{object_type})", default: nil) do
129
+ must_succeed('custom-fields') { collect_pages('custom-fields', params, conn: boot_connection) }
130
+ end
131
+ end
132
+
133
+ # The two memoized connections carry the bearer token in their headers, and
134
+ # `Faraday::Connection#inspect` prints those in clear: a Client reaching an
135
+ # `inspect` by accident leaks the credential whatever `Configuration` does
136
+ # about its own. Masked here for that reason, and not only for symmetry.
137
+ #
138
+ # The url goes through `redacted_url`, not `url`: a base url fronted by an
139
+ # egress proxy carries that proxy's credentials in its user-info, and this
140
+ # is one of the two places that print it.
141
+ def inspect
142
+ "#<#{self.class.name} base_url=#{@configuration.redacted_url.inspect}>"
143
+ end
144
+
145
+ private
146
+
147
+ def search_resource(path, limit:, cursor: nil, filter: nil, search_text: nil)
148
+ body = { 'limit' => clamp_limit(limit) }
149
+ body['cursor'] = cursor unless blank?(cursor)
150
+ body['filter'] = filter unless filter.nil?
151
+ body['search_text'] = search_text unless blank?(search_text)
152
+
153
+ must_succeed(path) { to_search_page(connection.post(path, body).body, path) }
154
+ end
155
+
156
+ # `limit` is mandatory on the paginated GET endpoints, unlike their POST
157
+ # /search counterparts which default it server-side.
158
+ def list_resource(path, limit:, cursor: nil)
159
+ params = { 'limit' => clamp_limit(limit) }
160
+ params['cursor'] = cursor unless blank?(cursor)
161
+
162
+ must_succeed(path) { to_search_page(connection.get(path, params).body, path) }
163
+ end
164
+
165
+ # The endpoints documented as answering with the whole collection in one
166
+ # response, `GET /users` and `GET /teams`. The cursor is still followed, the
167
+ # way `fetch_issue_messages` follows it: Pylon advertises no next page here,
168
+ # so the walk costs the single request it always did and only does something
169
+ # the day that changes.
170
+ #
171
+ # Past the cap the walk is refused rather than truncated, unlike the thread
172
+ # and the custom fields: `FetchAllCollection` filters, sorts, counts and
173
+ # groups in memory over what this returns, and calls the result exact
174
+ # BECAUSE it is every record Pylon holds. Truncated, that claim would stand
175
+ # over a fraction of the collection -- a count answered short and presented
176
+ # as exact, which is the one answer this datasource refuses everywhere else.
177
+ def fetch_all(path, params = {})
178
+ must_succeed(path) { collect_pages(path, params, refuse_past_cap: true) }
179
+ end
180
+
181
+ # Every record of a cursor-paginated GET, no window asked for and no limit
182
+ # sent. `CursorWalker` answers the other question — the offset/limit window a
183
+ # list view asks for — and is not what this needs.
184
+ #
185
+ # `params` ride along on every page, cursor included: a mandatory parameter
186
+ # dropped on the second request answers a different question than the first.
187
+ #
188
+ # An empty page, a cursor that does not move, a cursor already followed and
189
+ # the page cap all stop the loop: none happens today, but a walk driven by a
190
+ # remote value stops on its own terms rather than collecting the same page
191
+ # twice over.
192
+ #
193
+ # Only `next_cursor` being nil is Pylon saying there is nothing more. Every
194
+ # other stop leaves records behind, and `refuse_past_cap` is which of the two
195
+ # answers the caller wants to those: a warning and a short list, or a refusal.
196
+ # See `fetch_all`, whose answer is only correct whole.
197
+ #
198
+ # `conn` is what a caller reading on the boot path hands its own connection
199
+ # through: every page of the walk is then bounded like the first.
200
+ def collect_pages(path, params = {}, conn: connection, refuse_past_cap: false)
201
+ records = []
202
+ cursor = nil
203
+ seen = Set.new
204
+ pages = 0
205
+
206
+ loop do
207
+ query = cursor.nil? ? params : params.merge('cursor' => cursor)
208
+ page = to_search_page(conn.get(path, query).body, path)
209
+ records.concat(page.records)
210
+ pages += 1
211
+ break if page.next_cursor.nil?
212
+
213
+ reason = truncation_reason(page, pages, seen)
214
+ if reason
215
+ refuse_truncated_walk(path, reason, pages, records.size) if refuse_past_cap
216
+
217
+ log_truncated_walk(path, reason, pages, records.size)
218
+ break
219
+ end
220
+
221
+ cursor = page.next_cursor
222
+ end
223
+
224
+ records
225
+ end
226
+
227
+ # Why a walk stopped short of the end Pylon would have declared, or nil when
228
+ # it may go on. `seen` is fed here, once per page and only for a cursor that
229
+ # could still be followed.
230
+ def truncation_reason(page, pages, seen)
231
+ return 'it answered an empty page while advertising another' if page.records.empty?
232
+ return 'it answered a cursor it had already answered' unless seen.add?(page.next_cursor)
233
+ return "it paginated past the #{MAX_COLLECTED_PAGES} pages one walk covers" if pages >= MAX_COLLECTED_PAGES
234
+
235
+ nil
236
+ end
237
+
238
+ # Refused as an APIError, like a body whose shape broke the contract: what
239
+ # happened is that Pylon started answering an endpoint differently, which no
240
+ # filter the operator sets and no page they ask for would work around.
241
+ def refuse_truncated_walk(path, reason, pages, collected)
242
+ raise APIError,
243
+ "Pylon left the walk of #{path} short after #{pages} page(s) / #{collected} record(s): #{reason}. " \
244
+ 'That endpoint is documented as answering with the whole collection in one response, which is what ' \
245
+ 'PylonUser and PylonTeam filter, sort and count in memory as an exact answer. Refusing rather ' \
246
+ 'than answering over a fraction of the collection: reading it needs cursor pagination, the way ' \
247
+ 'PylonIssue reads its own.'
248
+ end
249
+
250
+ def log_truncated_walk(path, reason, pages, collected)
251
+ ForestAdminDatasourcePylon.logger.warn(
252
+ "[forest_admin_datasource_pylon] Stopped paginating #{path} after #{pages} page(s) / " \
253
+ "#{collected} record(s): #{reason}; the rest is left out."
254
+ )
255
+ end
256
+
257
+ # The id comes from operator-supplied filter values, so it is escaped before
258
+ # being joined to the path.
259
+ def fetch_resource(resource, id)
260
+ path = "#{resource}/#{Faraday::Utils.escape(id)}"
261
+ must_succeed(path) { extract_record(connection.get(path).body, path) }
262
+ end
263
+
264
+ def clamp_limit(limit)
265
+ value = limit.to_i
266
+ return 1 if value < 1
267
+
268
+ [value, MAX_SEARCH_LIMIT].min
269
+ end
270
+
271
+ # Pylon only includes the `pagination` block when a next page exists, so an
272
+ # absent block, `has_next_page: false` and an empty cursor all mean "done".
273
+ def to_search_page(body, operation)
274
+ pagination = body.is_a?(Hash) ? body['pagination'] : nil
275
+ cursor = pagination.is_a?(Hash) && pagination['has_next_page'] ? pagination['cursor'] : nil
276
+
277
+ SearchPage.new(records: extract_list(body, operation), next_cursor: blank?(cursor) ? nil : cursor)
278
+ end
279
+
280
+ # A read expects `data` to hold what it asked for, and anything else broke
281
+ # the contract. `extract_data` hands an envelope carrying no `data` straight
282
+ # back, which `Array()` would then split into `[key, value]` pairs and the
283
+ # collection would serialize into rows holding nothing -- a page that looks
284
+ # answered and is empty. Refused instead, the way the write path already
285
+ # refuses the same shape: see `Client::Writes#extract_written`.
286
+ #
287
+ # An absent or null `data` stays the empty answer it is: Pylon spells "no
288
+ # record" that way, and a search matching nothing is not a broken contract.
289
+ def extract_list(body, operation)
290
+ data = extract_data(body)
291
+ return [] if data.nil?
292
+ return data if data.is_a?(Array)
293
+
294
+ refuse_body_shape(body, operation, "'data' is not a list")
295
+ end
296
+
297
+ # The single-record half of the same check. nil travels: it is what a
298
+ # caller reads as "no such record".
299
+ def extract_record(body, operation)
300
+ data = extract_data(body)
301
+ return data if data.nil? || data.is_a?(Hash)
302
+
303
+ refuse_body_shape(body, operation, "'data' is not a record")
304
+ end
305
+
306
+ def blank?(value)
307
+ value.nil? || value.to_s.empty?
308
+ end
309
+
310
+ # Pylon wraps payloads in { "data": ..., "pagination": ..., "request_id": ... }.
311
+ def extract_data(body)
312
+ return nil if body.nil? || body == ''
313
+ return body['data'] if body.is_a?(Hash) && body.key?('data')
314
+
315
+ body
316
+ end
317
+
318
+ def must_succeed(operation)
319
+ yield
320
+ rescue Faraday::Error => e
321
+ raise api_error(operation, e)
322
+ rescue APIError
323
+ # Already mapped, with its status intact; re-wrapping would erase it.
324
+ raise
325
+ rescue StandardError => e
326
+ raise APIError, "Pylon API call failed: #{operation}: #{e.class}: #{e.message}"
327
+ end
328
+
329
+ # For the calls whose result enriches a page rather than being the page: the
330
+ # failure is reported and the default returned, so a degraded thread or a
331
+ # missing enrichment costs the operator a column, not the record they opened.
332
+ def best_effort(operation, default:)
333
+ yield
334
+ rescue StandardError => e
335
+ ForestAdminDatasourcePylon.logger.warn(
336
+ "[forest_admin_datasource_pylon] #{operation} failed; degrading: #{e.class}: #{e.message}"
337
+ )
338
+ default
339
+ end
340
+
341
+ # Builds an APIError preserving the HTTP status and Pylon's own error body so
342
+ # smart actions can show the operator the real reason instead of "failed".
343
+ def api_error(operation, error)
344
+ response = error.respond_to?(:response) ? error.response : nil
345
+ status = response.is_a?(Hash) ? response[:status] : nil
346
+ body = parse_body(response.is_a?(Hash) ? response[:body] : nil)
347
+ detail = error_detail(status, body) || "#{error.class}: #{error.message}"
348
+ APIError.new("Pylon API call failed: #{operation}: #{detail}", status: status, body: body)
349
+ end
350
+
351
+ def error_detail(status, body)
352
+ return nil unless status
353
+
354
+ "HTTP #{status} #{error_message(body)}".strip
355
+ end
356
+
357
+ def error_message(parsed)
358
+ return parsed.to_s[0, 500] unless parsed.is_a?(Hash)
359
+
360
+ nested = parsed['error']
361
+ message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
362
+ join_errors(parsed['errors'])
363
+ message = parsed.to_json if message.to_s.empty?
364
+ # Truncate before appending: the request_id is what support needs, so it
365
+ # must not be the first thing a long error body pushes out.
366
+ append_request_id(message.to_s[0, 500], parsed['request_id'])
367
+ end
368
+
369
+ def append_request_id(message, request_id)
370
+ return message unless request_id
371
+
372
+ "#{message} (request_id: #{request_id})"
373
+ end
374
+
375
+ def join_errors(errors)
376
+ Array(errors).filter_map { |e| e.is_a?(Hash) ? (e['message'] || e['detail']) : e }.join('; ')
377
+ end
378
+
379
+ def parse_body(body)
380
+ return body unless body.is_a?(String) && !body.empty?
381
+
382
+ JSON.parse(body)
383
+ rescue JSON::ParserError
384
+ body
385
+ end
386
+
387
+ def connection
388
+ @connection ||= build_connection(
389
+ retry_policy: @configuration.retry_policy,
390
+ timeout: @configuration.timeout,
391
+ open_timeout: @configuration.open_timeout
392
+ )
393
+ end
394
+
395
+ # For what is read while the datasource is being constructed: short timeouts
396
+ # and one quick retry, so the introspection cannot turn a Pylon that is down
397
+ # into minutes of Rails boot. Memoized separately from `connection`, which
398
+ # keeps the patience every later request is entitled to.
399
+ def boot_connection
400
+ @boot_connection ||= build_connection(
401
+ retry_policy: @configuration.boot_retry_policy,
402
+ timeout: @configuration.boot_timeout,
403
+ open_timeout: @configuration.boot_open_timeout
404
+ )
405
+ end
406
+
407
+ # Middleware order is deliberate: `raise_error` sits outside the JSON parser
408
+ # so it raises with an already-parsed body, and `retry` sits innermost so it
409
+ # inspects raw statuses — behind `raise_error` it would never see a 429.
410
+ #
411
+ # The throttle goes inside `retry`, which is what makes a replay wait for a
412
+ # slot like a first attempt: outside it, the middleware would run once for a
413
+ # request that reached Pylon three times.
414
+ #
415
+ # How long a request is allowed to take is the caller's to state, everything
416
+ # else being the same on every connection this builds: the limiter included,
417
+ # a second connection metering in a window of its own spending the budget of
418
+ # the endpoint twice over.
419
+ def build_connection(retry_policy:, timeout:, open_timeout:)
420
+ Faraday.new(url: @configuration.url) do |f|
421
+ f.request :json
422
+ f.response :raise_error
423
+ f.response :json
424
+ f.request :retry, **retry_policy.to_faraday_options
425
+ if @configuration.rate_limiter
426
+ f.use Throttle, limiter: @configuration.rate_limiter, base_path: @configuration.base_path
427
+ end
428
+ f.headers['Authorization'] = "Bearer #{@configuration.api_key}"
429
+ f.headers['Accept'] = 'application/json'
430
+ f.headers['User-Agent'] = "forest_admin_datasource_pylon/#{VERSION}"
431
+ f.options.open_timeout = open_timeout
432
+ f.options.timeout = timeout
433
+ end
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,43 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Account < CursorCollection
4
+ # The allow-list of `POST /accounts/search`, transcribed from the API
5
+ # reference: a field absent from this table cannot be filtered at all, and
6
+ # an operator absent from a field's map is rejected by Pylon.
7
+ #
8
+ # It is the single source of truth for filtering — `define_schema` derives
9
+ # every column's `filter_operators` from it, so this collection declares no
10
+ # filter the translator would then refuse; the absence family the agent
11
+ # derives on top of it is the exception `Query::OperatorMaps::Table`
12
+ # describes.
13
+ module ApiFilters
14
+ Maps = Query::OperatorMaps
15
+
16
+ extend Maps::Table
17
+
18
+ CUSTOM_FIELD_OPS = Maps::CUSTOM_FIELD_OPS
19
+
20
+ # `id` is filtered server-side here, which is what spares this
21
+ # collection the primary-key short-circuit Issue needs.
22
+ #
23
+ # `name` gets SUBSTRING rather than FULL_TEXT: the endpoint accepts
24
+ # `string_contains` but no negation of it. `external_ids` is left out
25
+ # entirely although the endpoint filters it — the API matches the bare
26
+ # external-id strings while the column shows `{external_id, label}`
27
+ # objects, so the filter would run on something the operator cannot see.
28
+ # The account read endpoint accepts an external id in place of the
29
+ # primary key, which is the way to reach a record by one.
30
+ #
31
+ # No time field is filterable: `created_at`, `updated_at` and
32
+ # `latest_customer_activity_time` are absent from the allow-list.
33
+ API_FILTERS = {
34
+ 'id' => { ops: Maps::EQUALITY },
35
+ 'name' => { ops: Maps::EQUALITY.merge(Maps::SUBSTRING) },
36
+ 'domains' => { ops: Maps::MEMBERSHIP },
37
+ 'tags' => { ops: Maps::MEMBERSHIP },
38
+ 'owner_id' => { ops: Maps::EQUALITY.merge(Maps::PRESENCE) }
39
+ }.freeze
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,91 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Account < CursorCollection
4
+ # A column is writable when `POST /accounts` or `PATCH /accounts/{id}`
5
+ # accepts it, in the shape it is read under — the Json columns holding
6
+ # objects rather than plain strings are left read-only, see below. No
7
+ # column is sortable — neither `GET /accounts` nor `POST /accounts/search`
8
+ # exposes a sort parameter, so advertising a sortable column would let the
9
+ # UI ask for an order the API cannot honour.
10
+ #
11
+ # Filter operators are not chosen here: they come from
12
+ # `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
13
+ # column missing from that table gets no operator, so the UI offers no
14
+ # filter of this collection's own that Pylon would refuse — the absence
15
+ # family the agent derives above the datasource being the exception
16
+ # `Query::OperatorMaps::Table` describes.
17
+ module SchemaDefinition
18
+ OneToManySchema = BaseCollection::OneToManySchema
19
+
20
+ private
21
+
22
+ def define_schema
23
+ define_identity_fields
24
+ define_domain_fields
25
+ define_ownership_fields
26
+ define_integration_fields
27
+ define_time_fields
28
+ end
29
+
30
+ # The reverse sides of the two ManyToOne relations pointing here. Both
31
+ # `/issues/search` and `/contacts/search` filter `account_id`
32
+ # server-side, so a related list is one request and no in-memory pass.
33
+ #
34
+ # `owner_id` stays a plain column: it does point at a PylonUser, and the
35
+ # embedder would resolve it like any other key, but nothing in the panel
36
+ # asks for the owner of an account yet.
37
+ def define_relations
38
+ add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
39
+ origin_key: 'account_id', origin_key_target: 'id'))
40
+ add_field('contacts', OneToManySchema.new(foreign_collection: 'PylonContact',
41
+ origin_key: 'account_id', origin_key_target: 'id'))
42
+ end
43
+
44
+ def define_identity_fields
45
+ add_column('id', 'String', is_primary_key: true)
46
+ add_column('name', 'String', writable: true)
47
+ # Left as String rather than Enum: Pylon ships customer / partner /
48
+ # prospect but lets an organization define its own account types. It
49
+ # is written under the name `account_type`, see `Account::RENAMES`.
50
+ add_column('type', 'String', writable: true)
51
+ # Writable on an update only: an account is created enabled.
52
+ add_column('is_disabled', 'Boolean', writable: true)
53
+ end
54
+
55
+ # `domain` and `primary_domain` carry the same value; both are kept
56
+ # because Pylon returns both, and only the `domains` list is filterable.
57
+ # Neither is writable: `domains` is the list the API takes, and writing
58
+ # one of its two projections would leave the other stale.
59
+ def define_domain_fields
60
+ add_column('domain', 'String')
61
+ add_column('primary_domain', 'String')
62
+ add_column('domains', 'Json', writable: true)
63
+ add_column('tags', 'Json', writable: true)
64
+ end
65
+
66
+ def define_ownership_fields
67
+ # Flattened from the nested `{ id: ..., email: ... }` object Pylon
68
+ # returns; a plain column, see `define_relations` above.
69
+ add_column('owner_id', 'String', writable: true)
70
+ # Read-only although the endpoint takes it: the column shows
71
+ # `{external_id, label}` objects, and the write shape the reference
72
+ # documents is not that one — writing one for the other would replace
73
+ # the ids of the account with something it cannot read.
74
+ add_column('external_ids', 'Json')
75
+ end
76
+
77
+ # Both belong to the integrations Pylon syncs them from: `crm_settings`
78
+ # is absent from every write endpoint, and `channels` — which they do
79
+ # take — holds objects, like `external_ids` above.
80
+ def define_integration_fields
81
+ add_column('channels', 'Json')
82
+ add_column('crm_settings', 'Json')
83
+ end
84
+
85
+ def define_time_fields
86
+ %w[created_at updated_at latest_customer_activity_time].each { |field| add_column(field, 'Date') }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,21 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Account < CursorCollection
4
+ module Serializer
5
+ NATIVE_FIELDS = %w[id name type is_disabled domain primary_domain domains tags external_ids
6
+ channels crm_settings created_at updated_at
7
+ latest_customer_activity_time].freeze
8
+
9
+ private
10
+
11
+ def serialize(account)
12
+ attrs = account.is_a?(Hash) ? account : {}
13
+ record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
14
+ record['owner_id'] = nested_id(attrs['owner'])
15
+ add_custom_field_values(record, attrs['custom_fields'])
16
+ record
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Account < CursorCollection
4
+ include SchemaDefinition
5
+ include Serializer
6
+
7
+ # Pylon reads an account's type back as `type` and takes it as
8
+ # `account_type`.
9
+ RENAMES = { 'type' => 'account_type' }.freeze
10
+
11
+ # An account is created enabled; only `PATCH /accounts/{id}` disables one.
12
+ UPDATE_ONLY = %w[is_disabled].freeze
13
+
14
+ def initialize(datasource, custom_fields: [])
15
+ super(datasource, 'PylonAccount', custom_fields: custom_fields, searchable: true)
16
+ end
17
+
18
+ protected
19
+
20
+ def filter_table = ApiFilters
21
+
22
+ def create_record(payload) = datasource.client.create_account(payload)
23
+ def update_record(id, payload) = datasource.client.update_account(id, payload)
24
+ def delete_record(id) = datasource.client.delete_account(id)
25
+
26
+ def update_only_fields = UPDATE_ONLY
27
+ def payload_renames = RENAMES
28
+
29
+ def unsortable_warning
30
+ '[forest_admin_datasource_pylon] PylonAccount cannot honour the requested order; neither GET /accounts ' \
31
+ 'nor POST /accounts/search takes a sort parameter, so accounts come back in the order the API imposes.'
32
+ end
33
+
34
+ def search_page(limit:, cursor:, filter:, search_text:)
35
+ datasource.client.search_accounts(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
36
+ end
37
+
38
+ def list_page(limit:, cursor:)
39
+ datasource.client.list_accounts(limit: limit, cursor: cursor)
40
+ end
41
+
42
+ def fetch_one(id)
43
+ datasource.client.fetch_account(id)
44
+ end
45
+ end
46
+ end
47
+ end