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,62 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Plugins
3
+ class CloseIssue
4
+ # What the operator reads once the batch ran. Every id that failed is
5
+ # named: an action reporting a plain success over a batch it only half
6
+ # applied is the one thing the panel cannot recover from.
7
+ module Messages
8
+ module_function
9
+
10
+ def success(succeeded, failed, state)
11
+ [succeeded_phrase(succeeded, state), failed_phrase(failed)].compact.join(' ')
12
+ end
13
+
14
+ def error(failed, state)
15
+ return "Failed to #{verb(state)} issue #{failed.first.first}: #{failed.first.last}" if failed.size == 1
16
+
17
+ "Failed to #{verb(state)} all #{failed.size} issues. First error: #{failed.first.last}"
18
+ end
19
+
20
+ # Worded around the selection rather than around the run: what the
21
+ # operator can act on is how many issues they picked, and the cap is
22
+ # named so the next attempt is a size they can aim for.
23
+ def too_many(count, state)
24
+ "This selection names #{count} Pylon issues, more than the #{CloseIssue::MAX_TARGETS} one run of " \
25
+ 'this action covers: Pylon takes one request per issue, and a run stopping halfway would leave ' \
26
+ "part of the selection #{past_verb(state)} without naming which part. Select fewer issues, and " \
27
+ 'run the action again on the rest.'
28
+ end
29
+
30
+ def no_target(field)
31
+ return 'No Pylon issue selected.' if field.nil?
32
+
33
+ "No Pylon issue id found in '#{field}'."
34
+ end
35
+
36
+ def succeeded_phrase(succeeded, state)
37
+ return nil if succeeded.empty?
38
+
39
+ return "Issue #{succeeded.first} #{past_verb(state)}." if succeeded.size == 1
40
+
41
+ "#{succeeded.size} issues #{past_verb(state)}."
42
+ end
43
+
44
+ def failed_phrase(failed)
45
+ return nil if failed.empty?
46
+
47
+ "#{failed.size} failed: #{failed.map(&:first).join(", ")}."
48
+ end
49
+
50
+ # A custom status is named as it is, where the state every organization
51
+ # has reads as the verb an operator used to fire the action.
52
+ def verb(state)
53
+ state == IssueEnums::CLOSED_STATE ? 'close' : "move to #{state}"
54
+ end
55
+
56
+ def past_verb(state)
57
+ state == IssueEnums::CLOSED_STATE ? 'closed' : "moved to #{state}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,141 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Plugins
3
+ # Moves the selected issues to a state, `closed` unless told otherwise.
4
+ #
5
+ # One variant per scope, where the Zendesk plugin builds four: Zendesk has
6
+ # two terminal statuses to tell apart, Pylon has `closed` and, past it, the
7
+ # custom status slugs an organization defines — which the `state` option
8
+ # takes, rather than a second dimension of action names nobody would read.
9
+ #
10
+ # The state is written through the client rather than through the
11
+ # collection: the action is registered on the host collection, which is
12
+ # rarely PylonIssue, and going through a collection would mean resolving it
13
+ # from a datasource the plugin was not given.
14
+ class CloseIssue < ForestAdminDatasourceCustomizer::Plugins::Plugin
15
+ BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
16
+ ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
17
+ ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
18
+
19
+ # What one run may write, the budget a filter-driven write already gets:
20
+ # this writes one PATCH per issue too, sequentially, and the action runs
21
+ # inside a single HTTP request. Past this the batch is refused before the
22
+ # first write rather than discovered after the twentieth, where a run cut
23
+ # short by a timeout leaves part of the selection moved and reports which
24
+ # part to nobody -- `apply_state` only names it once the loop returns.
25
+ #
26
+ # The collection cannot bound this: the action writes through the client,
27
+ # being registered on the host collection rather than on PylonIssue.
28
+ MAX_TARGETS = Collections::Writes::MAX_WRITE_REQUESTS
29
+
30
+ SCOPE_KEYS = %i[single bulk].freeze
31
+ SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze
32
+ NAMES = { single: 'Close Pylon issue', bulk: 'Close selected Pylon issues' }.freeze
33
+ NAME_OPTIONS = { single: :action_name, bulk: :bulk_action_name }.freeze
34
+
35
+ def run(_datasource_customizer, collection_customizer = nil, options = {})
36
+ opts = options.is_a?(Hash) ? options : {}
37
+ datasource = opts[:datasource]
38
+ raise ForestException, 'CloseIssue plugin requires :datasource' unless datasource
39
+ raise ForestException, 'CloseIssue plugin requires a collection' unless collection_customizer
40
+
41
+ state = normalize_state(opts[:state])
42
+
43
+ names = normalize_scopes(opts[:scopes]).to_h { |scope_key| [scope_key, name_for(scope_key, opts)] }
44
+ refuse_colliding_names(names)
45
+
46
+ names.each do |scope_key, name|
47
+ collection_customizer.add_action(name,
48
+ build_action(datasource, SCOPES[scope_key], state, opts[:issue_id_field]))
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Left unchecked against the states Pylon ships: it takes the slug of a
55
+ # custom status just as well, and refusing one would refuse the very
56
+ # workflow an organization built.
57
+ def normalize_state(value)
58
+ state = value.nil? ? IssueEnums::CLOSED_STATE : value.to_s.strip
59
+ return state unless state.empty?
60
+
61
+ raise ForestException, 'CloseIssue :state cannot be empty.'
62
+ end
63
+
64
+ # Through `to_s`: a value that is neither a string nor a symbol has no
65
+ # `to_sym`, and raising NoMethodError here would hide the unknown-scope
66
+ # error that names what was actually passed.
67
+ def normalize_scopes(value)
68
+ scopes = Array(value).map { |scope| scope.to_s.to_sym }.uniq
69
+ scopes = SCOPE_KEYS if scopes.empty?
70
+ unknown = scopes - SCOPE_KEYS
71
+ return scopes if unknown.empty?
72
+
73
+ raise ForestException,
74
+ "Unknown CloseIssue scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}."
75
+ end
76
+
77
+ # Through `to_s`, like the scopes: an action registers under the name it is
78
+ # given, and a Symbol reaching the schema breaks the agent rather than the
79
+ # action -- `GeneratorAction.get_action_slug` calls `strip` on it, and
80
+ # `GeneratorCollection` sorts the names of a collection's actions, which
81
+ # raises as soon as a Symbol sits beside a String. It also makes
82
+ # `:Resolve` and `'Resolve'` the one name they read as.
83
+ def name_for(scope_key, opts)
84
+ (opts[NAME_OPTIONS[scope_key]] || NAMES[scope_key]).to_s
85
+ end
86
+
87
+ # `add_action` keys a collection's actions by name, so two variants sharing
88
+ # one leave the second overwriting the first: the single-record action
89
+ # disappears from the panel and what stays answers with the other scope --
90
+ # a bulk action offered on one record, or the reverse. Refused at
91
+ # registration, like the other configurations this plugin will not perform
92
+ # quietly.
93
+ def refuse_colliding_names(names)
94
+ collision = names.values.tally.find { |_name, count| count > 1 }
95
+ return if collision.nil?
96
+
97
+ raise ForestException,
98
+ "CloseIssue registers one action per scope, and #{names.keys.join(" and ")} both resolve to " \
99
+ "'#{collision.first}'. Give :action_name and :bulk_action_name different values, or register " \
100
+ 'a single scope.'
101
+ end
102
+
103
+ def build_action(datasource, scope, state, issue_id_field)
104
+ BaseAction.new(scope: scope, &executor(datasource, state, issue_id_field))
105
+ end
106
+
107
+ def executor(datasource, state, issue_id_field)
108
+ lambda do |context, result_builder|
109
+ ids = IssueTargets.resolve_issue_ids(context, issue_id_field)
110
+ next result_builder.error(message: Messages.no_target(issue_id_field)) if ids.empty?
111
+ next result_builder.error(message: Messages.too_many(ids.size, state)) if ids.size > MAX_TARGETS
112
+
113
+ succeeded, failed = apply_state(datasource, ids, state)
114
+ next result_builder.error(message: Messages.error(failed, state)) if succeeded.empty?
115
+
116
+ result_builder.success(message: Messages.success(succeeded, failed, state))
117
+ end
118
+ end
119
+
120
+ # One rescue per id: a single issue Pylon refuses — deleted, or outside
121
+ # the token's scope — must not cost the operator the rest of a selection,
122
+ # and what failed is named in the message rather than left to a log.
123
+ def apply_state(datasource, ids, state)
124
+ succeeded = []
125
+ failed = []
126
+
127
+ ids.each do |id|
128
+ datasource.client.update_issue(id, 'state' => state)
129
+ succeeded << id
130
+ rescue StandardError => e
131
+ ForestAdminDatasourcePylon.logger.warn(
132
+ "[forest_admin_datasource_pylon] failed to move issue #{id} to '#{state}': #{e.class}: #{e.message}"
133
+ )
134
+ failed << [id, "#{e.class}: #{e.message}"]
135
+ end
136
+
137
+ [succeeded, failed]
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,173 @@
1
+ require 'cgi'
2
+
3
+ module ForestAdminDatasourcePylon
4
+ module Plugins
5
+ class CreateIssueWithNotification
6
+ module FormBuilder
7
+ FieldType = ForestAdminDatasourceCustomizer::Decorators::Action::Types::FieldType
8
+
9
+ NO_TEMPLATE = 'No template'.freeze
10
+ TOKEN_RE = /\{\{\s*record\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/
11
+
12
+ # Shared with `Payload`, which reads the submitted value back, and with
13
+ # the executor, which has to neutralise it when the form does not carry
14
+ # the field at all.
15
+ INTERNAL_NOTE_LABEL = 'Send as internal note'.freeze
16
+
17
+ module_function
18
+
19
+ # ActionCollectionDecorator rejects forms that mix Page elements with
20
+ # non-Page elements, so each mode (flat / wizard) stays homogeneous.
21
+ def build(opts)
22
+ body = body_fields(opts)
23
+ return body if opts[:email_templates].empty?
24
+
25
+ [
26
+ { type: 'Layout', component: 'Page', next_button_label: 'Continue',
27
+ elements: [template_field(opts[:email_templates])] },
28
+ { type: 'Layout', component: 'Page', previous_button_label: 'Back',
29
+ elements: body }
30
+ ]
31
+ end
32
+
33
+ # No Type field, unlike the Zendesk form: `POST /issues` does not take
34
+ # one -- Pylon accepts `type` on an update only, which is what
35
+ # `Issue::UPDATE_ONLY` already says.
36
+ def body_fields(opts)
37
+ fields = [requester_field(opts[:requester_email_default]),
38
+ subject_field(opts[:default_subject]),
39
+ message_field(opts[:default_message], opts[:email_templates])]
40
+ fields << priority_field unless present?(opts[:priority_override])
41
+ fields << internal_note_field if opts[:show_internal_note]
42
+ fields
43
+ end
44
+
45
+ def requester_field(default)
46
+ { type: FieldType::STRING, label: 'Requester email', is_required: true,
47
+ description: 'Email of the Pylon requester; the contact is created on the fly when it is unknown. ' \
48
+ 'Pre-filled from the selected record when available.',
49
+ default_value: requester_default(default) }
50
+ end
51
+
52
+ def template_field(templates)
53
+ { type: FieldType::ENUM, label: 'Template', is_required: true,
54
+ enum_values: [NO_TEMPLATE] + templates.map { |t| t[:title] },
55
+ default_value: NO_TEMPLATE,
56
+ description: 'Pick a template to pre-fill the Message on the next page.' }
57
+ end
58
+
59
+ def subject_field(default_subject)
60
+ { type: FieldType::STRING, label: 'Subject', is_required: true,
61
+ default_value: template_default(default_subject, escape_html: false) }
62
+ end
63
+
64
+ def message_field(default_message, templates)
65
+ field = { type: FieldType::STRING, label: 'Message', widget: 'RichText', is_required: true,
66
+ description: 'The body of the issue (HTML). Unless it is sent as an internal note, this is ' \
67
+ 'the message Pylon delivers to the requester.' }
68
+ default = template_default(default_message, escape_html: true)
69
+ return field.merge(default_value: default) if templates.empty?
70
+
71
+ # Both keys: `default_value:` fills the first render — drop_default
72
+ # runs once, the data key sticking after it — where `value:` is
73
+ # re-evaluated by drop_deferred on every fetch, which is what a
74
+ # Template change re-fires the message proc through.
75
+ field.merge(default_value: default, value: message_value(templates, default))
76
+ end
77
+
78
+ # No default: Pylon applies its own when the key is absent, and no
79
+ # priority is ever read back — the issue payload does not carry one, so
80
+ # nothing in Forest will show the operator what they picked.
81
+ def priority_field
82
+ { type: FieldType::ENUM, label: 'Priority', enum_values: IssueEnums::PRIORITY,
83
+ description: 'Set on creation only; Pylon does not return the priority of an issue, so it is not ' \
84
+ 'shown anywhere in Forest afterwards.' }
85
+ end
86
+
87
+ def internal_note_field
88
+ { type: FieldType::BOOLEAN, label: INTERNAL_NOTE_LABEL,
89
+ description: 'When checked, the issue is created without contacting the requester.',
90
+ default_value: false }
91
+ end
92
+
93
+ def requester_default(value)
94
+ return nil if value.nil?
95
+ return template_default(value, escape_html: false) if value.is_a?(String)
96
+
97
+ lambda do |context|
98
+ record = fetch_record(context)
99
+ record.empty? ? nil : value.call(record)
100
+ rescue StandardError => e
101
+ ForestAdminDatasourcePylon.logger.warn(
102
+ "[forest_admin_datasource_pylon] requester_email_default resolver raised: #{e.class}: #{e.message}"
103
+ )
104
+ nil
105
+ end
106
+ end
107
+
108
+ def template_default(template, escape_html:)
109
+ return nil unless present?(template)
110
+ return template unless template.match?(TOKEN_RE)
111
+
112
+ ->(context) { interpolate(template, fetch_record(context), escape_html: escape_html) }
113
+ end
114
+
115
+ # Returns nil unless Template was just changed, so set_watch_changes
116
+ # carries over the user's current Message edits between renders.
117
+ #
118
+ # Taking a template back restores the configured default rather than
119
+ # emptying a required field: it is what the operator was handed before
120
+ # they picked one.
121
+ def message_value(templates, default)
122
+ by_title = templates.to_h { |t| [t[:title], t[:content].to_s] }
123
+ lambda do |context|
124
+ return nil unless context.field_changed?('Template')
125
+
126
+ title = context.get_form_value('Template')
127
+ return evaluate_default(default, context) if title == NO_TEMPLATE
128
+
129
+ interpolated(by_title[title].to_s, context)
130
+ end
131
+ end
132
+
133
+ # A default carrying tokens is a proc, and one without is the string
134
+ # itself; no default at all empties the field, as it always did.
135
+ def evaluate_default(default, context)
136
+ default.is_a?(Proc) ? default.call(context) : default.to_s
137
+ end
138
+
139
+ def interpolated(content, context)
140
+ return content unless content.match?(TOKEN_RE)
141
+
142
+ interpolate(content, fetch_record(context), escape_html: true)
143
+ end
144
+
145
+ def fetch_record(context)
146
+ context.get_record([]) || {}
147
+ rescue StandardError => e
148
+ ForestAdminDatasourcePylon.logger.warn(
149
+ "[forest_admin_datasource_pylon] failed to fetch record for token interpolation: #{e.class}: #{e.message}"
150
+ )
151
+ {}
152
+ end
153
+
154
+ # The message ships as `body_html` and is delivered as such — an
155
+ # unescaped `<` or `&` coming from a record value would break the
156
+ # outbound message or smuggle markup into it.
157
+ def interpolate(template, record, escape_html:)
158
+ template.gsub(TOKEN_RE) do
159
+ key = ::Regexp.last_match(1)
160
+ value = record[key]
161
+ next '' if value.nil?
162
+
163
+ escape_html ? CGI.escapeHTML(value.to_s) : value.to_s
164
+ end
165
+ end
166
+
167
+ def present?(value)
168
+ !value.nil? && value.to_s != ''
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,72 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Plugins
3
+ class CreateIssueWithNotification
4
+ # What the filled form becomes on the wire, where FormBuilder owns what
5
+ # the operator fills.
6
+ module Payload
7
+ # Only meaningful on an email delivery: Pylon reads the sending address
8
+ # and the copies off the email app they belong to. The address is
9
+ # mandatory there — `CreateIssueWithNotification` refuses to register an
10
+ # email delivery without one.
11
+ EMAIL_DESTINATION = 'email'.freeze
12
+
13
+ module_function
14
+
15
+ def build(values, email, opts)
16
+ payload = {
17
+ 'title' => values['Subject'],
18
+ 'body_html' => values['Message'],
19
+ # Pylon wants a name alongside the address when it creates the
20
+ # contact; derive it from the local part. It is ignored when the
21
+ # contact already exists.
22
+ 'requester_email' => email,
23
+ 'requester_name' => derive_requester_name(email)
24
+ }
25
+ priority = opts[:priority_override] || values['Priority']
26
+ payload['priority'] = priority if present?(priority)
27
+
28
+ destination = destination_for(values, opts)
29
+ payload['destination_metadata'] = metadata(destination, opts) unless internal?(destination)
30
+ payload
31
+ end
32
+
33
+ # The checkbox wins over the configured destination: it is the
34
+ # operator's call, made on the record they are looking at.
35
+ def destination_for(values, opts)
36
+ truthy?(values[FormBuilder::INTERNAL_NOTE_LABEL]) ? IssueEnums::INTERNAL_DESTINATION : opts[:destination]
37
+ end
38
+
39
+ # An internal issue travels as no metadata at all rather than as
40
+ # `{destination: 'internal'}`: that is the form the API reference names
41
+ # for "do not contact the requester", and the one that stays right if
42
+ # Pylon ever adds a required companion field to a real destination.
43
+ def internal?(destination)
44
+ destination == IssueEnums::INTERNAL_DESTINATION
45
+ end
46
+
47
+ def metadata(destination, opts)
48
+ metadata = { 'destination' => destination }
49
+ return metadata unless destination == EMAIL_DESTINATION
50
+
51
+ metadata['email'] = opts[:sender_email]
52
+ metadata['email_ccs'] = Array(opts[:email_ccs]) if Array(opts[:email_ccs]).any?
53
+ metadata['email_bccs'] = Array(opts[:email_bccs]) if Array(opts[:email_bccs]).any?
54
+ metadata
55
+ end
56
+
57
+ def derive_requester_name(email)
58
+ local = email.to_s.split('@').first.to_s
59
+ local.empty? ? email.to_s : local
60
+ end
61
+
62
+ def truthy?(value)
63
+ value == true || value.to_s.casecmp('true').zero?
64
+ end
65
+
66
+ def present?(value)
67
+ !value.nil? && value.to_s != ''
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,175 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Plugins
3
+ # Opens a Pylon issue and delivers its first message to the requester.
4
+ #
5
+ # Pylon creates the contact on the fly from the form's email, so the action
6
+ # can be registered on any host collection — no relation to Pylon needed.
7
+ #
8
+ # Where Zendesk notifies as a side effect of a public comment, Pylon says it
9
+ # outright: `destination_metadata.destination` names the channel the
10
+ # issue's `body_html` is delivered through, and no `destination_metadata` at
11
+ # all is what leaves the issue internal. The "Send as internal note"
12
+ # checkbox is that choice, worded the way the Zendesk plugin words it.
13
+ #
14
+ # The form is FormBuilder's, the wire payload is Payload's; what is left
15
+ # here is the registration, its options, and what the operator reads back.
16
+ class CreateIssueWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin
17
+ BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
18
+ ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
19
+ ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
20
+
21
+ NAME = 'Create Pylon issue and notify'.freeze
22
+
23
+ def run(_datasource_customizer, collection_customizer = nil, options = {})
24
+ options = {} unless options.is_a?(Hash)
25
+ datasource = options[:datasource]
26
+ raise ForestException, 'CreateIssueWithNotification plugin requires :datasource' unless datasource
27
+ raise ForestException, 'CreateIssueWithNotification plugin requires a collection' unless collection_customizer
28
+
29
+ opts = options.except(:datasource)
30
+ opts[:email_templates] = normalize_templates(opts[:email_templates])
31
+ opts[:destination] = normalize_destination(opts[:destination])
32
+ opts[:priority_override] = normalize_priority(opts[:priority_override])
33
+ require_sender_email!(opts)
34
+
35
+ collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts))
36
+ end
37
+
38
+ private
39
+
40
+ # The title is what the enum carries and what the content is looked up
41
+ # by, so it has to name one template: a duplicate makes the first
42
+ # unreachable and would send the other one's content under its name, and
43
+ # the sentinel of the "pick nothing" option makes the template it names
44
+ # unpickable. Both are configuration, so both are refused at registration
45
+ # rather than discovered by whoever sends the wrong message.
46
+ def normalize_templates(value)
47
+ templates = Array(value).compact
48
+ titles = templates.map { |template| template[:title].to_s }
49
+
50
+ reserved = titles.include?(FormBuilder::NO_TEMPLATE)
51
+ raise ForestException, "An email template cannot be titled #{FormBuilder::NO_TEMPLATE.inspect}." if reserved
52
+
53
+ duplicated = titles.tally.select { |_title, count| count > 1 }.keys
54
+ raise ForestException, "Duplicate email template titles: #{duplicated.join(", ")}." if duplicated.any?
55
+
56
+ templates
57
+ end
58
+
59
+ def normalize_destination(value)
60
+ return Payload::EMAIL_DESTINATION if value.nil?
61
+
62
+ normalize(value, IssueEnums::DESTINATION, 'destination')
63
+ end
64
+
65
+ # `POST /issues` refuses an email delivery that does not name the address it
66
+ # is sent from, so the option is mandatory there rather than optional. The
67
+ # refusal belongs at registration, where it names the option and the agent
68
+ # will not boot without it, rather than at the first execution, where it
69
+ # reaches the operator as a Pylon 400 on a form they filled correctly.
70
+ def require_sender_email!(opts)
71
+ return unless opts[:destination] == Payload::EMAIL_DESTINATION
72
+ return if Payload.present?(opts[:sender_email])
73
+
74
+ raise ForestException,
75
+ 'CreateIssueWithNotification requires :sender_email when the destination is email. ' \
76
+ 'It must be one of the addresses configured in the Pylon email app.'
77
+ end
78
+
79
+ def normalize_priority(value)
80
+ return nil unless Payload.present?(value)
81
+
82
+ normalize(value, IssueEnums::PRIORITY, 'priority')
83
+ end
84
+
85
+ def normalize(value, allowed, label)
86
+ normalized = value.to_s
87
+ return normalized if allowed.include?(normalized)
88
+
89
+ raise ForestException,
90
+ "Unknown CreateIssueWithNotification #{label}: #{normalized}. Allowed: #{allowed.join(", ")}."
91
+ end
92
+
93
+ def build_action(datasource, opts)
94
+ BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts))
95
+ end
96
+
97
+ def executor(datasource, opts)
98
+ lambda do |context, result_builder|
99
+ values = submitted_values(context, opts)
100
+ email = values['Requester email']
101
+ next result_builder.error(message: 'Requester email is required.') unless Payload.present?(email)
102
+
103
+ issue = create_issue(datasource, Payload.build(values, email, opts))
104
+ next result_builder.error(message: issue.last) if issue.is_a?(Array)
105
+
106
+ writeback = write_back_issue_id(context, opts[:issue_id_field], issue['id'])
107
+ result_builder.success(message: success_message(issue, values, opts, writeback))
108
+ end
109
+ end
110
+
111
+ # The form only carries the internal-note checkbox when the option asked
112
+ # for it, but a form value is not what the form offered: the agent copies
113
+ # every key the request sent, matched against a field or not. Left as
114
+ # submitted, the flag would create an internal issue on an action
115
+ # registered without it — and the requester the plugin exists to notify
116
+ # would not be. What the form does not show cannot be sent.
117
+ def submitted_values(context, opts)
118
+ values = context.form_values
119
+ return values if opts[:show_internal_note]
120
+
121
+ values.merge(FormBuilder::INTERNAL_NOTE_LABEL => false)
122
+ end
123
+
124
+ # A 4xx is Pylon naming what the operator filled in, and reaches them as
125
+ # the action's own error, message intact: raised, it would leave the agent
126
+ # to answer 'Unexpected error' — APIError is none of the classes whose
127
+ # message the translator passes through — and to log nothing either, its
128
+ # status being under 500. Anything else is Pylon or the network failing,
129
+ # which no edit of the form would change, and stays the 500 it is.
130
+ def create_issue(datasource, payload)
131
+ datasource.client.create_issue(payload)
132
+ rescue APIError => e
133
+ raise unless (400..499).cover?(e.status.to_i)
134
+
135
+ ForestAdminDatasourcePylon.logger.warn(
136
+ "[forest_admin_datasource_pylon] Pylon refused the issue creation: #{e.message}"
137
+ )
138
+ [:rejected, e.message]
139
+ end
140
+
141
+ # Best-effort: Pylon has no transaction to roll back, and the issue exists
142
+ # whether or not the host record could be stamped with its id.
143
+ def write_back_issue_id(context, field, issue_id)
144
+ return :skipped if field.nil?
145
+
146
+ context.collection.update(context.filter, { field => issue_id })
147
+ :ok
148
+ rescue StandardError => e
149
+ ForestAdminDatasourcePylon.logger.warn(
150
+ "[forest_admin_datasource_pylon] failed to store the issue id in '#{field}': #{e.class}: #{e.message}"
151
+ )
152
+ [:failed, "#{e.class}: #{e.message}"]
153
+ end
154
+
155
+ def success_message(issue, values, opts, writeback)
156
+ base = base_success_message(issue, values, opts)
157
+ return base unless writeback.is_a?(Array) && writeback.first == :failed
158
+
159
+ "#{base} (warning: could not store the issue id on the record: #{writeback.last})"
160
+ end
161
+
162
+ # The number is what an operator recognises an issue by; the id stands in
163
+ # when Pylon answered without one.
164
+ def base_success_message(issue, values, opts)
165
+ reference = issue['number'] || issue['id']
166
+ destination = Payload.destination_for(values, opts)
167
+ if Payload.internal?(destination)
168
+ return "Issue ##{reference} created (internal, the requester was not contacted)."
169
+ end
170
+
171
+ "Issue ##{reference} created and the requester notified by #{destination.tr("_", " ")}."
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,51 @@
1
+ module ForestAdminDatasourcePylon
2
+ module Plugins
3
+ # Which Pylon issues an action was fired on.
4
+ #
5
+ # Two shapes, one option: `issue_id_field` names a column of the host
6
+ # collection holding a Pylon issue id — a business collection keeping the
7
+ # issue it opened — and, left out, the ids are the primary keys of the
8
+ # selected records, which is what an action registered on PylonIssue itself
9
+ # acts on.
10
+ module IssueTargets
11
+ module_function
12
+
13
+ # A collection whose column was renamed, or a record the scope hides,
14
+ # answers "no issue selected" through the action's own message rather than
15
+ # through a stack trace in the panel.
16
+ #
17
+ # A ValidationError is the exception: it is the datasource refusing the
18
+ # selection in words written for the operator — PylonIssue naming more
19
+ # issues by id than one page of lookups covers, for one — and the agent
20
+ # surfaces its message as it is. Swallowed, it would reach them as "no
21
+ # issue selected" about a selection they can see they made, which is the
22
+ # one thing the message must not say.
23
+ def resolve_issue_ids(context, field = nil)
24
+ ids = field.nil? ? primary_key_ids(context) : column_ids(context, field)
25
+ # Deduplicated: a column of issue ids is not a key, so two selected
26
+ # records may name the same issue, which would then be written twice and
27
+ # counted twice in what the action reports back.
28
+ ids.filter_map do |id|
29
+ id.to_s unless id.nil? || id.to_s.empty?
30
+ end.uniq
31
+ rescue ForestAdminDatasourceToolkit::Exceptions::ValidationError
32
+ raise
33
+ rescue StandardError => e
34
+ source = field ? "from '#{field}'" : 'from the selected records'
35
+ ForestAdminDatasourcePylon.logger.warn(
36
+ "[forest_admin_datasource_pylon] failed to resolve the issues to act on #{source}: " \
37
+ "#{e.class}: #{e.message}"
38
+ )
39
+ []
40
+ end
41
+
42
+ def primary_key_ids(context)
43
+ Array(context.get_record_ids)
44
+ end
45
+
46
+ def column_ids(context, field)
47
+ context.get_records([field.to_s]).map { |record| record[field.to_s] }
48
+ end
49
+ end
50
+ end
51
+ end