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,89 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ # The conversation of an issue, embedded as a structured array column the
5
+ # way the Zendesk datasource embeds a ticket's comments.
6
+ #
7
+ # Pylon has no way to read the threads of several issues at once, so the
8
+ # thread costs one request per row — against an endpoint allowing 20 per
9
+ # minute. The fan-out is therefore bounded like the primary-key lookups of
10
+ # this collection: truncated with a warning rather than turned into a rate
11
+ # limit error halfway through the page. A relation the projection does not
12
+ # ask for costs no request at all.
13
+ module MessagesEmbedder
14
+ include RecordSerialization
15
+
16
+ private
17
+
18
+ # The thread is read only when the projection names it. A nil projection
19
+ # — what a count or an export goes through — asks for the record as
20
+ # Pylon returns it, and embeds nothing, exactly like RelationEmbedder:
21
+ # spending one request per row on a path that never asked for the
22
+ # conversation is the very fan-out MAX_MESSAGE_EMBEDS exists to bound.
23
+ def want_messages?(projection)
24
+ Array(projection).map(&:to_s).any? { |p| p == 'messages' || p.start_with?('messages:') }
25
+ end
26
+
27
+ # A row past the cap, and a row whose thread failed to be read, are left
28
+ # at nil: "unknown", never the empty list, which would read as "this
29
+ # issue has no message" — the kind of answer that looks complete without
30
+ # being it.
31
+ def embed_messages(records, rows)
32
+ embedded = rows.first(MAX_MESSAGE_EMBEDS)
33
+ warn_truncated_threads(rows.size) if rows.size > embedded.size
34
+
35
+ embedded.each_with_index do |row, index|
36
+ messages = datasource.client.fetch_issue_messages(records[index]['id'])
37
+ row['messages'] = messages&.map { |message| serialize_message(message) }
38
+ end
39
+ end
40
+
41
+ def serialize_message(message)
42
+ attrs = message.is_a?(Hash) ? message : {}
43
+
44
+ {
45
+ 'id' => attrs['id'],
46
+ 'body_html' => attrs['message_html'],
47
+ 'is_private' => attrs['is_private'],
48
+ 'source' => attrs['source'],
49
+ 'thread_id' => attrs['thread_id'],
50
+ 'file_urls' => attrs['file_urls'],
51
+ 'created_at' => attrs['timestamp']
52
+ }.merge(flatten_author(attrs['author']))
53
+ end
54
+
55
+ # Pylon nests the author's contact and user sides side by side, both
56
+ # optional and with nothing telling them apart: a message written by an
57
+ # agent carries `user`, one written by a customer carries `contact`. Both
58
+ # ids are kept, so a message stays traceable to the PylonContact or
59
+ # PylonUser record it came from, and the email is taken from whichever
60
+ # side is there.
61
+ def flatten_author(author)
62
+ attrs = author.is_a?(Hash) ? author : {}
63
+ contact = attrs['contact']
64
+ user = attrs['user']
65
+
66
+ {
67
+ 'author_name' => attrs['name'],
68
+ 'author_avatar_url' => attrs['avatar_url'],
69
+ 'author_email' => nested_email(contact) || nested_email(user),
70
+ 'author_contact_id' => nested_id(contact),
71
+ 'author_user_id' => nested_id(user)
72
+ }
73
+ end
74
+
75
+ def nested_email(value)
76
+ value['email'] if value.is_a?(Hash)
77
+ end
78
+
79
+ def warn_truncated_threads(asked)
80
+ ForestAdminDatasourcePylon.logger.warn(
81
+ "[forest_admin_datasource_pylon] Asked for the message thread of #{asked} issues, reading the first " \
82
+ "#{MAX_MESSAGE_EMBEDS}: one request per issue, each carrying a whole conversation. " \
83
+ 'Narrow the selection, or take the thread out of the projection, to reach the records past this point.'
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,122 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ # A column is writable when `POST /issues` or `PATCH /issues/{id}` accepts
5
+ # it, the two directions being told apart by `Issue::CREATE_ONLY` and
6
+ # `Issue::UPDATE_ONLY`; everything Pylon computes stays read-only. No
7
+ # column is sortable, `/issues/search` exposing no sort parameter at all:
8
+ # results always come back ordered by `created_at` descending, so
9
+ # advertising a sortable column would let the UI ask for an order the API
10
+ # cannot honour.
11
+ #
12
+ # Filter operators are not chosen here: they come from
13
+ # `ApiFilters::API_FILTERS`, which mirrors the allow-list of the API. A
14
+ # column missing from that table gets no operator, so the UI offers no
15
+ # filter of this collection's own that Pylon would refuse — the absence
16
+ # family the agent derives above the datasource being the exception
17
+ # `Query::OperatorMaps::Table` describes.
18
+ module SchemaDefinition
19
+ ColumnSchema = BaseCollection::ColumnSchema
20
+ ManyToOneSchema = BaseCollection::ManyToOneSchema
21
+ Operators = BaseCollection::Operators
22
+
23
+ private
24
+
25
+ def define_schema
26
+ define_identity_fields
27
+ define_content_fields
28
+ define_party_fields
29
+ define_time_fields
30
+ end
31
+
32
+ # The four parties of an issue, each pointing at the collection owning its
33
+ # shape: the flattened `*_id` columns stay, as the keys the relation is
34
+ # read through and as the columns the search endpoint filters.
35
+ #
36
+ # RelationEmbedder resolves them at read time, in bulk. The reverse sides
37
+ # are declared by the collections they belong to; `/issues/search`
38
+ # filters every one of these keys, so they are answered server-side.
39
+ def define_relations
40
+ add_field('account', ManyToOneSchema.new(foreign_collection: 'PylonAccount',
41
+ foreign_key: 'account_id', foreign_key_target: 'id'))
42
+ add_field('requester', ManyToOneSchema.new(foreign_collection: 'PylonContact',
43
+ foreign_key: 'requester_id', foreign_key_target: 'id'))
44
+ add_field('assignee', ManyToOneSchema.new(foreign_collection: 'PylonUser',
45
+ foreign_key: 'assignee_id', foreign_key_target: 'id'))
46
+ add_field('team', ManyToOneSchema.new(foreign_collection: 'PylonTeam',
47
+ foreign_key: 'team_id', foreign_key_target: 'id'))
48
+ end
49
+
50
+ def define_identity_fields
51
+ # Only equal/in: these are the two the primary-key short-circuit can
52
+ # actually serve through GET /issues/{id}. `id` is not part of the
53
+ # search allow-list, so it never reaches the translator.
54
+ add_field('id', ColumnSchema.new(column_type: 'String',
55
+ filter_operators: [Operators::EQUAL, Operators::IN],
56
+ is_primary_key: true, is_groupable: false, is_read_only: true))
57
+ add_column('number', 'Number')
58
+ add_column('link', 'String')
59
+ end
60
+
61
+ def define_content_fields
62
+ add_column('title', 'String', writable: true)
63
+ # Writable on creation only: it is the first message of the thread,
64
+ # which `PATCH /issues/{id}` does not carry.
65
+ add_column('body_html', 'String', writable: true)
66
+ # Left as String rather than Enum: Pylon ships five built-in states
67
+ # but organisations define their own on top of them. Writable on an
68
+ # update only — every issue is created `new`.
69
+ add_column('state', 'String', writable: true)
70
+ add_column('type', 'String', writable: true)
71
+ # Where the issue came from: Pylon sets it, no endpoint takes it.
72
+ add_column('source', 'String')
73
+ add_column('tags', 'Json', writable: true)
74
+ add_column('customer_portal_visible', 'Boolean', writable: true)
75
+ add_column('author_unverified', 'Boolean', writable: true)
76
+ add_column('number_of_touches', 'Number')
77
+ define_thread_field
78
+ end
79
+
80
+ # The conversation, embedded at read time by MessagesEmbedder. Declared
81
+ # by hand rather than through `add_column`: its type is the shape of one
82
+ # message, not a primitive.
83
+ #
84
+ # Neither filterable nor sortable — `POST /issues/search` covers no
85
+ # message field, and the thread is not even part of the payload the
86
+ # search endpoint returns — and not groupable either: `ColumnSchema`
87
+ # defaults that flag to true, where the `add_column` of the base passes
88
+ # false for every Pylon column, a thread being both an array and a value
89
+ # the pages of a cursor walk do not carry.
90
+ def define_thread_field
91
+ add_field('messages', ColumnSchema.new(column_type: [Issue::MESSAGE_THREAD_SCHEMA],
92
+ filter_operators: [], is_groupable: false,
93
+ is_read_only: true))
94
+ end
95
+
96
+ # Flattened from the nested `{id: …}` objects Pylon returns, and kept as
97
+ # columns next to the relations they are the keys of: they are what the
98
+ # search endpoint filters, on this side and on the reverse one.
99
+ #
100
+ # Writable, although `GeneratorField` forces a foreign key read-only in
101
+ # the emitted schema whatever the datasource says, so the detail view has
102
+ # one editor per key rather than two. What the flag opens is that editor:
103
+ # the `BelongsTo` reads its own read-only state off the key column, and
104
+ # the front sends the choice back as the very column named here.
105
+ def define_party_fields
106
+ %w[account_id requester_id assignee_id team_id].each do |field|
107
+ add_column(field, 'String', writable: true)
108
+ end
109
+ end
110
+
111
+ def define_time_fields
112
+ %w[first_response_time resolution_time latest_message_time created_at updated_at].each do |field|
113
+ add_column(field, 'Date')
114
+ end
115
+ %w[time_in_status_seconds business_hours_time_in_status_seconds].each do |field|
116
+ add_column(field, 'Json')
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,26 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ module Serializer
5
+ PARTY_FIELDS = { 'account_id' => 'account', 'requester_id' => 'requester',
6
+ 'assignee_id' => 'assignee', 'team_id' => 'team' }.freeze
7
+
8
+ NATIVE_FIELDS = %w[id number link title body_html state type source tags
9
+ customer_portal_visible author_unverified number_of_touches
10
+ first_response_time resolution_time latest_message_time
11
+ created_at updated_at time_in_status_seconds
12
+ business_hours_time_in_status_seconds].freeze
13
+
14
+ private
15
+
16
+ def serialize(issue)
17
+ attrs = issue.is_a?(Hash) ? issue : {}
18
+ record = NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
19
+ PARTY_FIELDS.each { |column, source| record[column] = nested_id(attrs[source]) }
20
+ add_custom_field_values(record, attrs['custom_fields'])
21
+ record
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,128 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Issue < BaseCollection
4
+ include SchemaDefinition
5
+ include RecordSerialization
6
+ include Serializer
7
+ include RelationEmbedder
8
+ include MessagesEmbedder
9
+ include IdLookupReader
10
+
11
+ # `/issues/search` exposes no sort parameter, so the allow-list is empty and
12
+ # every requested order is reported instead of being silently swallowed.
13
+ # The mechanism stays in place for the collections whose endpoint sorts.
14
+ PYLON_SORTABLE = {}.freeze
15
+
16
+ # A primary-key lookup spends one `GET /issues/{id}` per id, sequentially.
17
+ # The fan-out is bounded like the cursor walk, and for the same reason:
18
+ # `RateLimiter` keeps the requests inside the 300 a minute that endpoint
19
+ # grants, but nothing makes two hundred round-trips fast.
20
+ #
21
+ # What it bounds is one page, not the selection behind it: the window is
22
+ # taken off the ids first, so a selection wider than this is read a page
23
+ # at a time rather than truncated at its first #{MAX_ID_LOOKUPS}. See
24
+ # `IdLookupReader`, and the one case that cannot be paged.
25
+ MAX_ID_LOOKUPS = 20
26
+
27
+ # The shape of one message inside the `messages` column. Field names follow
28
+ # the columns of this collection rather than the payload: Pylon spells them
29
+ # `message_html` and `timestamp`, which would put two conventions in the
30
+ # same schema for the operator to reconcile.
31
+ MESSAGE_THREAD_SCHEMA = {
32
+ 'id' => 'String',
33
+ 'body_html' => 'String',
34
+ 'is_private' => 'Boolean',
35
+ 'source' => 'String',
36
+ 'thread_id' => 'String',
37
+ 'file_urls' => 'Json',
38
+ 'created_at' => 'Date',
39
+ 'author_name' => 'String',
40
+ 'author_email' => 'String',
41
+ 'author_avatar_url' => 'String',
42
+ 'author_contact_id' => 'String',
43
+ 'author_user_id' => 'String'
44
+ }.freeze
45
+
46
+ # One thread is one `GET /issues/{id}/messages`, and a thread is the whole
47
+ # conversation rather than a page of it. A list view asking for more of
48
+ # them than this reads the first ones and reports the rest, for the same
49
+ # reason MAX_ID_LOOKUPS bounds the primary-key fan-out — sequential
50
+ # round-trips, each carrying an unbounded payload. Lower than
51
+ # MAX_ID_LOOKUPS because of that payload, not because of the quota: the
52
+ # endpoint grants 120 requests a minute.
53
+ MAX_MESSAGE_EMBEDS = 10
54
+
55
+ # `body_html` is the first message of the thread, which `POST /issues`
56
+ # requires and `PATCH /issues/{id}` does not carry; `author_unverified`
57
+ # qualifies that message and travels with it.
58
+ CREATE_ONLY = %w[body_html author_unverified].freeze
59
+
60
+ # Pylon creates every issue as `new`, of the type it decides, and takes
61
+ # both on an update only.
62
+ UPDATE_ONLY = %w[state type].freeze
63
+
64
+ def initialize(datasource, custom_fields: [])
65
+ super(datasource, 'PylonIssue', custom_fields: custom_fields, searchable: true)
66
+ end
67
+
68
+ def list(caller, filter, projection)
69
+ records = fetch_records(caller, filter)
70
+ rows = records.map { |record| project(record, projection) }
71
+ embed_relations(records, rows, projection)
72
+ embed_messages(records, rows) if want_messages?(projection)
73
+ rows
74
+ end
75
+
76
+ protected
77
+
78
+ def filter_table = ApiFilters
79
+
80
+ def create_record(payload) = datasource.client.create_issue(payload)
81
+ def update_record(id, payload) = datasource.client.update_issue(id, payload)
82
+ def delete_record(id) = datasource.client.delete_issue(id)
83
+
84
+ def create_only_fields = CREATE_ONLY
85
+ def update_only_fields = UPDATE_ONLY
86
+
87
+ # An issue is read through `GET /issues/{id}`, one request per record: a
88
+ # selection resolved or compared that way spends the write budget twice
89
+ # over, so it divides the records one pass reaches rather than fitting
90
+ # beside them.
91
+ def requests_per_record_read = 1
92
+
93
+ # Never past the primary-key fan-out either: a write resolving named ids
94
+ # through `list` reads them one request apiece, and a resolution the page
95
+ # cap trimmed would write to a subset of the selection while reporting the
96
+ # whole of it. The budget is the tighter of the two at today's numbers;
97
+ # the clamp keeps that true if either moves.
98
+ def max_resolvable_ids(reads: 0) = [super, MAX_ID_LOOKUPS].min
99
+
100
+ def sortable_fields
101
+ PYLON_SORTABLE
102
+ end
103
+
104
+ def unsortable_warning
105
+ '[forest_admin_datasource_pylon] PylonIssue cannot honour the requested order; ' \
106
+ 'POST /issues/search always returns issues from the most recent to the oldest.'
107
+ end
108
+
109
+ def search_page(limit:, cursor:, filter:, search_text:)
110
+ datasource.client.search_issues(limit: limit, cursor: cursor, filter: filter, search_text: search_text)
111
+ end
112
+
113
+ private
114
+
115
+ def fetch_records(caller, filter)
116
+ warn_unsortable(filter&.sort)
117
+
118
+ with_resolved_relations(caller, filter) do |query|
119
+ lookup = extract_id_lookup(query&.condition_tree)
120
+ next search_records(caller, query) unless lookup
121
+
122
+ ensure_searchless_lookup!(query)
123
+ records_by_id(caller, lookup, query)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,84 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ # The two parts of a Pylon payload every collection reads the same way: the
4
+ # nested `{ id: ... }` objects it flattens into foreign-key columns, and the
5
+ # custom fields the organization defined, which are columns of their own.
6
+ module RecordSerialization
7
+ private
8
+
9
+ def nested_id(value)
10
+ value['id'] if value.is_a?(Hash)
11
+ end
12
+
13
+ def add_custom_field_values(record, values)
14
+ custom_fields.each do |cf|
15
+ entry = values.is_a?(Hash) ? values[cf[:column_name]] : nil
16
+ record[cf[:column_name]] = coerce_custom_field(custom_field_value(entry), cf[:schema].column_type)
17
+ end
18
+ end
19
+
20
+ # Pylon spells a custom field as `slug => {"slug": ..., "value": ...}`,
21
+ # with `"values": [...]` instead of `"value"` for multi-value fields.
22
+ def custom_field_value(entry)
23
+ return entry unless entry.is_a?(Hash)
24
+
25
+ entry.key?('value') ? entry['value'] : entry['values']
26
+ end
27
+
28
+ # The API reference documents what a custom field *is*, never the form its
29
+ # value is read back in, so a Number answering `"42"` is not ruled out. A
30
+ # value that is not already of its column's type is therefore converted --
31
+ # the in-memory pass of the primary-key short-circuit would otherwise drop
32
+ # a row on `"42" == 42.0`, comparing a read string with the float
33
+ # `ConditionTreeParser.cast_to_type` casts the filter to.
34
+ #
35
+ # A date stays the string it is: the filter carries an ISO8601 string too,
36
+ # and comparing two of those is the ordering itself.
37
+ def coerce_custom_field(value, column_type)
38
+ return nil if value.nil?
39
+
40
+ case column_type
41
+ when 'Number' then coerce_number(value)
42
+ when 'Boolean' then coerce_boolean(value)
43
+ else value
44
+ end
45
+ end
46
+
47
+ # A value already numeric is handed back untouched: `ConditionTreeLeaf#match`
48
+ # compares with `==` and `Array#include?`, both of which hold across Integer
49
+ # and Float, so nothing needs widening -- and widening would make an integer
50
+ # field display the `12.0` it does not hold, where `FilterValue#format_float`
51
+ # narrows the very same value on the way out. A string is read to the
52
+ # tightest form for that reason, the two halves agreeing on what an integer
53
+ # looks like.
54
+ #
55
+ # A number that cannot be read reads as absent rather than as zero.
56
+ #
57
+ # Integers are parsed as integers rather than reached through a Float: past
58
+ # 2**53 the intermediate loses the last digits, and `"9007199254740993"`
59
+ # would be both displayed and compared as ...992. Base 10 is passed
60
+ # explicitly -- `Integer("012")` is 10, Ruby reading a leading zero as
61
+ # octal, which would silently renumber every zero-padded value Pylon holds.
62
+ def coerce_number(value)
63
+ return value if value.is_a?(Numeric)
64
+
65
+ integer = Integer(value, 10, exception: false)
66
+ return integer unless integer.nil?
67
+
68
+ float = Float(value, exception: false)
69
+ return nil if float.nil?
70
+
71
+ float == float.to_i ? float.to_i : float
72
+ end
73
+
74
+ # An empty boolean reads as absent as well: `false` is an answer of its own,
75
+ # and Pylon gave none.
76
+ def coerce_boolean(value)
77
+ return value if [true, false].include?(value)
78
+ return nil if value.to_s.strip.empty?
79
+
80
+ !%w[false 0 no].include?(value.to_s.strip.downcase)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,101 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ # Forest asks for a ManyToOne relation as `relation:field` entries in the
4
+ # projection and expects the related record nested under the relation name on
5
+ # every row. Pylon has no join and no include parameter, so the records are
6
+ # read from the foreign collection — in bulk, from the foreign keys the
7
+ # serialized records already carry, never one request per row.
8
+ #
9
+ # What to embed comes from the schema rather than from a list kept here: a
10
+ # collection embeds whatever ManyToOne relations it declares, and a relation
11
+ # the projection does not ask for costs no request at all.
12
+ module RelationEmbedder
13
+ ManyToOneSchema = BaseCollection::ManyToOneSchema
14
+ Projection = BaseCollection::Projection
15
+
16
+ private
17
+
18
+ # `records` are the serialized records, carrying the foreign keys `project`
19
+ # strips off the rows; `rows` are the projected rows, in the same order.
20
+ def embed_relations(records, rows, projection)
21
+ # Rebuilt rather than read off the argument: `list` is also called with
22
+ # a plain array of field names, inside the agent and in the specs, and a
23
+ # relation read from one of those would go unprojected -- which is the
24
+ # one shape this must not have.
25
+ sub_projections = Projection.new(Array(projection).map(&:to_s)).relations
26
+
27
+ projected_relations(projection).group_by { |_name, relation| relation.foreign_collection }
28
+ .each do |foreign, group|
29
+ embed_foreign(foreign, group, records, rows, sub_projections)
30
+ end
31
+ end
32
+
33
+ # Grouped by foreign collection, so two relations pointing at the same one
34
+ # are answered by a single read and their ids are deduped together.
35
+ #
36
+ # A relation the projection asked for is written on every row, whether or
37
+ # not it resolved: a null foreign key, and a record the operator can no
38
+ # longer reach, both read as "no related record" rather than as a row
39
+ # missing the field.
40
+ #
41
+ # The nested record is cut down to the fields the projection named, the
42
+ # way `project` cuts the row it sits on. The foreign collection hands back
43
+ # everything it holds -- Pylon has no join, so a related record is read
44
+ # through its own endpoint, which takes no field list -- and nesting that
45
+ # whole record would answer `account:name` with every column of the
46
+ # account, the ones the caller's permissions had the agent take out of the
47
+ # projection included.
48
+ #
49
+ # The primary key is added back rather than assumed: the route's
50
+ # projection carries it, but the nested record is what the serializer
51
+ # reads an `included` resource's id off, and a caller inside the agent may
52
+ # well have projected without it.
53
+ def embed_foreign(foreign_collection, relations, records, rows, sub_projections)
54
+ ids = foreign_ids(records, relations)
55
+ foreign = datasource.get_collection(foreign_collection)
56
+ indexed = ids.empty? ? {} : foreign.records_indexed_by_id(ids)
57
+ relations.each do |name, relation|
58
+ wanted = projected_fields(sub_projections[name], foreign)
59
+ rows.each_with_index do |row, index|
60
+ related = indexed[records[index][relation.foreign_key]]
61
+ row[name] = wanted ? wanted.re_project(related) : related
62
+ end
63
+ end
64
+ end
65
+
66
+ # The fields of the relation, plus the primary key of the collection it
67
+ # points at. The key is unioned in by hand rather than through
68
+ # `Projection#with_pks`, which also walks the relations of the projection
69
+ # it is given and dereferences their schema without a nil guard: a
70
+ # projection reaching through a relation this collection does not declare
71
+ # would raise there, where it used to be ignored. Only the immediate
72
+ # foreign key is wanted here, so only that is added.
73
+ def projected_fields(sub_projection, foreign)
74
+ return nil if sub_projection.nil?
75
+
76
+ Projection.new(sub_projection | ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(foreign))
77
+ end
78
+
79
+ # A foreign key Pylon left empty asks for nothing — a blank one no more
80
+ # than a null one, and it would reach the `in` filter of the read below,
81
+ # which refuses a blank inside a list and would fail the whole page over
82
+ # one malformed key. The same id is asked for once however many rows point
83
+ # at it.
84
+ def foreign_ids(records, relations)
85
+ keys = relations.map { |_name, relation| relation.foreign_key }
86
+ records.flat_map { |record| keys.map { |key| record[key] } }
87
+ .reject { |id| id.nil? || id.to_s.empty? }
88
+ .uniq
89
+ end
90
+
91
+ # `account:name` asks for the `account` relation; a projected column, and a
92
+ # relation that is not a ManyToOne, name no field to embed.
93
+ def projected_relations(projection)
94
+ Array(projection).map { |field| field.to_s.split(':').first }.uniq.filter_map do |name|
95
+ relation = schema[:fields][name]
96
+ [name, relation] if relation.is_a?(ManyToOneSchema)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,49 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class Team < FetchAllCollection
4
+ def initialize(datasource)
5
+ super(datasource, 'PylonTeam')
6
+ end
7
+
8
+ protected
9
+
10
+ # Pylon exposes no DELETE on a team, so that hook is left to refuse.
11
+ def create_record(payload) = datasource.client.create_team(payload)
12
+ def update_record(id, payload) = datasource.client.update_team(id, payload)
13
+
14
+ def fetch_all
15
+ datasource.client.fetch_teams
16
+ end
17
+
18
+ # `users` is flattened to the ids of the members: the emails Pylon nests
19
+ # there belong to PylonUser, which is where they stay up to date.
20
+ def serialize(team)
21
+ attrs = team.is_a?(Hash) ? team : {}
22
+
23
+ { 'id' => attrs['id'], 'name' => attrs['name'],
24
+ 'user_ids' => Array(attrs['users']).filter_map { |user| user['id'] if user.is_a?(Hash) } }
25
+ end
26
+
27
+ private
28
+
29
+ # `/issues/search` filters `team_id` server-side, so the issues assigned to
30
+ # a team are listed by one request. `user_ids` gets no relation: Pylon
31
+ # nests the members here rather than pointing at the team from a user, so
32
+ # the membership is a ManyToMany with no join collection to declare it on.
33
+ def define_relations
34
+ add_field('issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
35
+ origin_key: 'team_id', origin_key_target: 'id'))
36
+ end
37
+
38
+ def define_schema
39
+ add_column('id', 'String', is_primary_key: true)
40
+ add_column('name', 'String', writable: true)
41
+ # A list, so neither filterable nor sortable, and no relation either:
42
+ # see `define_relations` above. `POST /teams` and `PATCH /teams/{id}`
43
+ # take the members as this very list, and the one sent replaces the
44
+ # membership whole.
45
+ add_column('user_ids', 'Json', writable: true)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,69 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Collections
3
+ class User < FetchAllCollection
4
+ NATIVE_FIELDS = %w[id name email emails avatar_url status role_id is_deactivated].freeze
5
+
6
+ def initialize(datasource)
7
+ super(datasource, 'PylonUser')
8
+ end
9
+
10
+ protected
11
+
12
+ # Pylon exposes no POST and no DELETE on a user: an agent is invited and
13
+ # deactivated from Pylon itself, so the other two hooks are left to refuse.
14
+ def update_record(id, payload) = datasource.client.update_user(id, payload)
15
+
16
+ # `include_deactivated` is left at the client default of true on purpose:
17
+ # a deactivated agent stays the assignee and the author of the issues they
18
+ # handled, and a record the rest of the panel points at has to stay
19
+ # readable. `is_deactivated` is exposed as a column so an operator can
20
+ # filter them out when they want to.
21
+ def fetch_all
22
+ datasource.client.fetch_users
23
+ end
24
+
25
+ # `role` is flattened to its name only: its id is already carried by
26
+ # `role_id`, and the name is what an operator recognises. Its slug is left
27
+ # out — Pylon derives it from the name.
28
+ def serialize(user)
29
+ attrs = user.is_a?(Hash) ? user : {}
30
+ role = attrs['role']
31
+
32
+ NATIVE_FIELDS.to_h { |field| [field, attrs[field]] }
33
+ .merge('role_name' => role.is_a?(Hash) ? role['name'] : nil)
34
+ end
35
+
36
+ private
37
+
38
+ # `/issues/search` filters `assignee_id` server-side, so the issues of an
39
+ # agent are listed by one request.
40
+ #
41
+ # The teams of a user are left out: Pylon nests the members inside a team
42
+ # and exposes no team id on a user, so that side is a ManyToMany with no
43
+ # key column to build it on.
44
+ def define_relations
45
+ add_field('assigned_issues', OneToManySchema.new(foreign_collection: 'PylonIssue',
46
+ origin_key: 'assignee_id', origin_key_target: 'id'))
47
+ end
48
+
49
+ # `PATCH /users/{id}` takes the name, the avatar, the role and the status,
50
+ # and nothing else.
51
+ def define_schema
52
+ add_column('id', 'String', is_primary_key: true)
53
+ add_column('name', 'String', writable: true)
54
+ add_column('email', 'String')
55
+ # The other addresses of the same agent: a list, so it is neither
56
+ # filterable nor sortable. `email` carries the primary one.
57
+ add_column('emails', 'Json')
58
+ add_column('avatar_url', 'String', writable: true)
59
+ # Left as String rather than Enum: Pylon documents active / away /
60
+ # out_of_office on the update endpoint, but does not promise the read
61
+ # side is limited to them.
62
+ add_column('status', 'String', writable: true)
63
+ add_column('role_id', 'String', writable: true)
64
+ add_column('role_name', 'String')
65
+ add_column('is_deactivated', 'Boolean')
66
+ end
67
+ end
68
+ end
69
+ end