forest_admin_datasource_zendesk 1.29.2 → 1.30.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.
- checksums.yaml +4 -4
- data/lib/forest_admin_datasource_zendesk/client/writes.rb +19 -6
- data/lib/forest_admin_datasource_zendesk/collections/ticket/schema_definition.rb +6 -3
- data/lib/forest_admin_datasource_zendesk/collections/ticket.rb +0 -4
- data/lib/forest_admin_datasource_zendesk/collections/user.rb +3 -12
- data/lib/forest_admin_datasource_zendesk/plugins/close_ticket/errors.rb +34 -0
- data/lib/forest_admin_datasource_zendesk/plugins/close_ticket/messages.rb +41 -0
- data/lib/forest_admin_datasource_zendesk/plugins/close_ticket.rb +123 -0
- data/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification/form_builder.rb +149 -0
- data/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb +103 -0
- data/lib/forest_admin_datasource_zendesk/ticket_enums.rb +8 -0
- data/lib/forest_admin_datasource_zendesk/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad6908798b1d792d32e301c8e54d1ca2639d672da304922beb90344a1385268d
|
|
4
|
+
data.tar.gz: 0156ed5162187cee0bd193573f67517ede4ab166d5bc6c76b65d25cb9a3f1797
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2134b9998033a3ef4b084066687fd25739eb463c62e686e241b278172f2c51bc098a43d3759dd19ded41668a0de2861ed1428d4a64c52b68e8311082e4a55b82
|
|
7
|
+
data.tar.gz: 71c953aef092085be491cf2089d00b7e72d9222465150049b39214297c6ac2627cd271951575697759297cf5c4814ad39f92f878243a37786bcd2cd2c113f0e9
|
|
@@ -16,17 +16,30 @@ module ForestAdminDatasourceZendesk
|
|
|
16
16
|
private
|
|
17
17
|
|
|
18
18
|
def post_resource(path, key, attributes)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
body
|
|
19
|
+
op = "create(#{path})"
|
|
20
|
+
body = must_succeed(op) do
|
|
21
|
+
api.connection.post(path) { |req| req.body = { key => attributes } }.body
|
|
22
22
|
end
|
|
23
|
+
extract_resource(body, key, op)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def put_resource(path, key, id, attributes)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
body
|
|
27
|
+
op = "update(#{path}/#{id})"
|
|
28
|
+
body = must_succeed(op) do
|
|
29
|
+
api.connection.put("#{path}/#{id}") { |req| req.body = { key => attributes } }.body
|
|
29
30
|
end
|
|
31
|
+
extract_resource(body, key, op)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Zendesk wraps create/update responses in `{ "<resource>": { ... } }`.
|
|
35
|
+
# An empty or differently-shaped body means the API contract broke —
|
|
36
|
+
# surface a typed error rather than handing back a confusing envelope.
|
|
37
|
+
def extract_resource(body, key, operation)
|
|
38
|
+
resource = body[key] if body.is_a?(Hash)
|
|
39
|
+
return resource if resource.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
raise APIError,
|
|
42
|
+
"Zendesk API #{operation} returned an unexpected body shape (missing '#{key}'): #{body.inspect}"
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
def delete_resource(path, id)
|
|
@@ -18,11 +18,14 @@ module ForestAdminDatasourceZendesk
|
|
|
18
18
|
add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
19
19
|
is_read_only: false, is_sortable: false))
|
|
20
20
|
add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
|
|
21
|
-
enum_values:
|
|
21
|
+
enum_values: TicketEnums::STATUS, is_read_only: false,
|
|
22
|
+
is_sortable: true))
|
|
22
23
|
add_field('priority', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
|
|
23
|
-
enum_values:
|
|
24
|
+
enum_values: TicketEnums::PRIORITY, is_read_only: false,
|
|
25
|
+
is_sortable: true))
|
|
24
26
|
add_field('ticket_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
|
|
25
|
-
enum_values:
|
|
27
|
+
enum_values: TicketEnums::TYPE, is_read_only: false,
|
|
28
|
+
is_sortable: true))
|
|
26
29
|
add_field('requester_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
27
30
|
is_read_only: false, is_sortable: true))
|
|
28
31
|
add_field('assignee_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
@@ -8,10 +8,6 @@ module ForestAdminDatasourceZendesk
|
|
|
8
8
|
|
|
9
9
|
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
10
10
|
|
|
11
|
-
ENUM_STATUS = %w[new open pending hold solved closed].freeze
|
|
12
|
-
ENUM_PRIORITY = %w[low normal high urgent].freeze
|
|
13
|
-
ENUM_TYPE = %w[problem incident question task].freeze
|
|
14
|
-
|
|
15
11
|
ZENDESK_SORTABLE = {
|
|
16
12
|
'updated_at' => 'updated_at',
|
|
17
13
|
'created_at' => 'created_at',
|
|
@@ -6,6 +6,8 @@ module ForestAdminDatasourceZendesk
|
|
|
6
6
|
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
7
7
|
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
|
|
8
8
|
ENUM_ROLE = %w[end-user agent admin].freeze
|
|
9
|
+
BASE_ATTR_KEYS = %w[id email name role phone organization_id time_zone locale verified suspended
|
|
10
|
+
created_at updated_at].freeze
|
|
9
11
|
|
|
10
12
|
ZENDESK_SORTABLE = {
|
|
11
13
|
'created_at' => 'created_at',
|
|
@@ -100,22 +102,11 @@ module ForestAdminDatasourceZendesk
|
|
|
100
102
|
|
|
101
103
|
def serialize(user)
|
|
102
104
|
attrs = attrs_of(user)
|
|
103
|
-
result =
|
|
105
|
+
result = BASE_ATTR_KEYS.to_h { |k| [k, attrs[k]] }
|
|
104
106
|
user_fields = attrs['user_fields'] || {}
|
|
105
107
|
@custom_fields.each { |cf| result[cf[:column_name]] = user_fields[cf[:zendesk_key]] }
|
|
106
108
|
result
|
|
107
109
|
end
|
|
108
|
-
|
|
109
|
-
def base_attributes(attrs)
|
|
110
|
-
{
|
|
111
|
-
'id' => attrs['id'], 'email' => attrs['email'], 'name' => attrs['name'],
|
|
112
|
-
'role' => attrs['role'], 'phone' => attrs['phone'],
|
|
113
|
-
'organization_id' => attrs['organization_id'],
|
|
114
|
-
'time_zone' => attrs['time_zone'], 'locale' => attrs['locale'],
|
|
115
|
-
'verified' => attrs['verified'], 'suspended' => attrs['suspended'],
|
|
116
|
-
'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at']
|
|
117
|
-
}
|
|
118
|
-
end
|
|
119
110
|
end
|
|
120
111
|
end
|
|
121
112
|
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Plugins
|
|
3
|
+
class CloseTicket
|
|
4
|
+
# Decoding helpers for Zendesk's structured update-error payloads.
|
|
5
|
+
module Errors
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Zendesk refuses any update on a closed ticket with this exact
|
|
9
|
+
# wording on the `status` field — detected so we can swap the raw
|
|
10
|
+
# stack for a clean message.
|
|
11
|
+
ALREADY_CLOSED_DESCRIPTION = 'closed prevents ticket update'.freeze
|
|
12
|
+
|
|
13
|
+
def already_closed?(error)
|
|
14
|
+
invalid = unwrap_record_invalid(error)
|
|
15
|
+
return false unless invalid
|
|
16
|
+
|
|
17
|
+
status_errors = invalid.errors.is_a?(Hash) ? Array(invalid.errors['status']) : []
|
|
18
|
+
status_errors.any? do |entry|
|
|
19
|
+
entry.is_a?(Hash) && entry['description'].to_s.include?(ALREADY_CLOSED_DESCRIPTION)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def unwrap_record_invalid(error)
|
|
24
|
+
while error
|
|
25
|
+
return error if error.is_a?(ZendeskAPI::Error::RecordInvalid)
|
|
26
|
+
|
|
27
|
+
error = error.cause
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Plugins
|
|
3
|
+
class CloseTicket
|
|
4
|
+
module Messages
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def success(succeeded, already_closed, failed, status)
|
|
8
|
+
[succeeded_phrase(succeeded, status), already_closed_phrase(already_closed),
|
|
9
|
+
failed_phrase(failed)].compact.join(' ')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def error(failed, status)
|
|
13
|
+
verb = status == 'closed' ? 'close' : 'mark as solved'
|
|
14
|
+
return "Failed to #{verb} ticket ##{failed.first.first}: #{failed.first.last}" if failed.size == 1
|
|
15
|
+
|
|
16
|
+
"Failed to #{verb} all #{failed.size} tickets. First error: #{failed.first.last}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def succeeded_phrase(succeeded, status)
|
|
20
|
+
return nil if succeeded.empty?
|
|
21
|
+
|
|
22
|
+
verb = status == 'closed' ? 'closed' : 'marked as solved'
|
|
23
|
+
succeeded.size == 1 ? "Ticket ##{succeeded.first} #{verb}." : "#{succeeded.size} tickets #{verb}."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def already_closed_phrase(already_closed)
|
|
27
|
+
return nil if already_closed.empty?
|
|
28
|
+
return "Ticket ##{already_closed.first} was already closed." if already_closed.size == 1
|
|
29
|
+
|
|
30
|
+
"#{already_closed.size} tickets were already closed: #{already_closed.join(", ")}."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def failed_phrase(failed)
|
|
34
|
+
return nil if failed.empty?
|
|
35
|
+
|
|
36
|
+
"#{failed.size} failed: #{failed.map(&:first).join(", ")}."
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Plugins
|
|
3
|
+
# The Zendesk ticket id is read from a configurable column on the host
|
|
4
|
+
# record(s); Zendesk sometimes rejects the direct `open -> closed`
|
|
5
|
+
# transition so failures are surfaced per-id rather than retried.
|
|
6
|
+
class CloseTicket < ForestAdminDatasourceCustomizer::Plugins::Plugin
|
|
7
|
+
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
|
|
8
|
+
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
|
|
9
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
10
|
+
|
|
11
|
+
STATUSES = %w[solved closed].freeze
|
|
12
|
+
SCOPE_KEYS = %i[single bulk].freeze
|
|
13
|
+
|
|
14
|
+
NAMES = {
|
|
15
|
+
'solved' => { single: 'Mark Zendesk ticket as solved',
|
|
16
|
+
bulk: 'Mark selected Zendesk tickets as solved' }.freeze,
|
|
17
|
+
'closed' => { single: 'Mark Zendesk ticket as closed',
|
|
18
|
+
bulk: 'Mark selected Zendesk tickets as closed' }.freeze
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze
|
|
22
|
+
|
|
23
|
+
def run(_datasource_customizer, collection_customizer = nil, options = {})
|
|
24
|
+
datasource = options[:datasource]
|
|
25
|
+
ticket_id_field = options[:ticket_id_field]
|
|
26
|
+
raise ForestException, 'CloseTicket plugin requires :datasource' unless datasource
|
|
27
|
+
raise ForestException, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field
|
|
28
|
+
raise ForestException, 'CloseTicket plugin requires a collection' unless collection_customizer
|
|
29
|
+
|
|
30
|
+
statuses = normalize_statuses(options[:statuses])
|
|
31
|
+
scopes = normalize_scopes(options[:scopes])
|
|
32
|
+
|
|
33
|
+
variants(statuses, scopes).each do |name, status, scope|
|
|
34
|
+
collection_customizer.add_action(name, build_action(datasource, status, scope, ticket_id_field))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def normalize_statuses(value)
|
|
41
|
+
normalize(value, :to_s, STATUSES, 'statuses')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_scopes(value)
|
|
45
|
+
normalize(value, :to_sym, SCOPE_KEYS, 'scopes')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize(value, cast, allowed, label)
|
|
49
|
+
list = Array(value).map(&cast).uniq
|
|
50
|
+
list = allowed if list.empty?
|
|
51
|
+
unknown = list - allowed
|
|
52
|
+
return list if unknown.empty?
|
|
53
|
+
|
|
54
|
+
raise ForestException,
|
|
55
|
+
"Unknown CloseTicket #{label}: #{unknown.join(", ")}. Allowed: #{allowed.join(", ")}."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def variants(statuses, scopes)
|
|
59
|
+
statuses.flat_map do |status|
|
|
60
|
+
scopes.map { |scope_key| [NAMES[status][scope_key], status, SCOPES[scope_key]] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_action(datasource, status, scope, ticket_id_field)
|
|
65
|
+
BaseAction.new(scope: scope, &executor(datasource, status, ticket_id_field))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def executor(datasource, status, ticket_id_field)
|
|
69
|
+
lambda do |context, result_builder|
|
|
70
|
+
ids = resolve_ticket_ids(context, ticket_id_field)
|
|
71
|
+
next result_builder.error(message: "No Zendesk ticket id found in '#{ticket_id_field}'.") if ids.empty?
|
|
72
|
+
|
|
73
|
+
succeeded, already_closed, failed = apply_status(datasource, ids, status)
|
|
74
|
+
|
|
75
|
+
# Closed tickets can't be reopened to 'solved'; fold into failures.
|
|
76
|
+
if status == 'solved'
|
|
77
|
+
failed += already_closed.map { |id| [id, 'ticket is already closed (cannot reopen to mark as solved)'] }
|
|
78
|
+
already_closed = []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if succeeded.empty? && already_closed.empty?
|
|
82
|
+
result_builder.error(message: Messages.error(failed, status))
|
|
83
|
+
else
|
|
84
|
+
result_builder.success(message: Messages.success(succeeded, already_closed, failed, status))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def resolve_ticket_ids(context, ticket_id_field)
|
|
90
|
+
records = context.get_records([ticket_id_field])
|
|
91
|
+
records.filter_map { |r| r[ticket_id_field.to_s] }
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
94
|
+
"[forest_admin_datasource_zendesk] failed to resolve ticket ids from '#{ticket_id_field}': " \
|
|
95
|
+
"#{e.class}: #{e.message}"
|
|
96
|
+
)
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Per-id rescue so a single transition rejection doesn't abort bulk.
|
|
101
|
+
def apply_status(datasource, ids, status)
|
|
102
|
+
succeeded = []
|
|
103
|
+
already_closed = []
|
|
104
|
+
failed = []
|
|
105
|
+
ids.each do |id|
|
|
106
|
+
datasource.client.update_ticket(id, 'status' => status)
|
|
107
|
+
succeeded << id
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
if Errors.already_closed?(e)
|
|
110
|
+
already_closed << id
|
|
111
|
+
else
|
|
112
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
113
|
+
"[forest_admin_datasource_zendesk] failed to set ticket ##{id} to '#{status}': " \
|
|
114
|
+
"#{e.class}: #{e.message}"
|
|
115
|
+
)
|
|
116
|
+
failed << [id, "#{e.class}: #{e.message}"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
[succeeded, already_closed, failed]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification/form_builder.rb
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceZendesk
|
|
4
|
+
module Plugins
|
|
5
|
+
class CreateTicketWithNotification
|
|
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
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# ActionCollectionDecorator rejects forms that mix Page elements with
|
|
15
|
+
# non-Page elements, so each mode (flat / wizard) stays homogeneous.
|
|
16
|
+
def build(opts)
|
|
17
|
+
body = body_fields(opts)
|
|
18
|
+
return body if opts[:email_templates].empty?
|
|
19
|
+
|
|
20
|
+
[
|
|
21
|
+
{ type: 'Layout', component: 'Page', next_button_label: 'Continue',
|
|
22
|
+
elements: [template_field(opts[:email_templates])] },
|
|
23
|
+
{ type: 'Layout', component: 'Page', previous_button_label: 'Back',
|
|
24
|
+
elements: body }
|
|
25
|
+
]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def body_fields(opts)
|
|
29
|
+
fields = [requester_field(opts[:requester_email_default]),
|
|
30
|
+
subject_field(opts[:default_subject]),
|
|
31
|
+
message_field(opts[:default_message], opts[:email_templates])]
|
|
32
|
+
fields << priority_field unless present?(opts[:priority_override])
|
|
33
|
+
fields << type_field unless present?(opts[:type_override])
|
|
34
|
+
fields << internal_note_field if opts[:show_internal_note]
|
|
35
|
+
fields
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def requester_field(default)
|
|
39
|
+
{ type: FieldType::STRING, label: 'Requester email', is_required: true,
|
|
40
|
+
description: 'Email of the Zendesk requester. Pre-filled from the selected record when available.',
|
|
41
|
+
default_value: requester_default(default) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def template_field(templates)
|
|
45
|
+
{ type: FieldType::ENUM, label: 'Template', is_required: true,
|
|
46
|
+
enum_values: [NO_TEMPLATE] + templates.map { |t| t[:title] },
|
|
47
|
+
default_value: NO_TEMPLATE,
|
|
48
|
+
description: 'Pick a template to pre-fill the Message on the next page.' }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def subject_field(default_subject)
|
|
52
|
+
{ type: FieldType::STRING, label: 'Subject', is_required: true,
|
|
53
|
+
default_value: template_default(default_subject, escape_html: false) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def message_field(default_message, templates)
|
|
57
|
+
field = { type: FieldType::STRING, label: 'Message', widget: 'RichText', is_required: true,
|
|
58
|
+
description: 'Sent as the ticket\'s first comment (HTML). Public comments trigger the ' \
|
|
59
|
+
'default Zendesk notification email to the requester.' }
|
|
60
|
+
return field.merge(default_value: template_default(default_message, escape_html: true)) if templates.empty?
|
|
61
|
+
|
|
62
|
+
# `value:` (not `default_value:`) — drop_default runs once (data
|
|
63
|
+
# key sticks after the first render); drop_deferred re-evaluates
|
|
64
|
+
# on every fetch, so Template changes re-fire the message proc.
|
|
65
|
+
field.merge(value: message_value(templates))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def priority_field
|
|
69
|
+
{ type: FieldType::ENUM, label: 'Priority',
|
|
70
|
+
enum_values: TicketEnums::PRIORITY, default_value: 'normal' }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def type_field
|
|
74
|
+
{ type: FieldType::ENUM, label: 'Type', enum_values: TicketEnums::TYPE }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def internal_note_field
|
|
78
|
+
{ type: FieldType::BOOLEAN, label: 'Send as internal note',
|
|
79
|
+
description: 'When checked, the first comment is private and no email is sent to the requester.',
|
|
80
|
+
default_value: false }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def requester_default(value)
|
|
84
|
+
return nil if value.nil?
|
|
85
|
+
return template_default(value, escape_html: false) if value.is_a?(String)
|
|
86
|
+
|
|
87
|
+
lambda do |context|
|
|
88
|
+
record = fetch_record(context)
|
|
89
|
+
record.empty? ? nil : value.call(record)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
92
|
+
"[forest_admin_datasource_zendesk] requester_email_default resolver raised: #{e.class}: #{e.message}"
|
|
93
|
+
)
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def template_default(template, escape_html:)
|
|
99
|
+
return nil unless present?(template)
|
|
100
|
+
return template unless template.match?(TOKEN_RE)
|
|
101
|
+
|
|
102
|
+
->(context) { interpolate(template, fetch_record(context), escape_html: escape_html) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns nil unless Template was just changed, so set_watch_changes
|
|
106
|
+
# carries over the user's current Message edits between renders.
|
|
107
|
+
def message_value(templates)
|
|
108
|
+
by_title = templates.to_h { |t| [t[:title], t[:content].to_s] }
|
|
109
|
+
lambda do |context|
|
|
110
|
+
return nil unless context.field_changed?('Template')
|
|
111
|
+
|
|
112
|
+
title = context.get_form_value('Template')
|
|
113
|
+
return '' if title == NO_TEMPLATE
|
|
114
|
+
|
|
115
|
+
content = by_title[title].to_s
|
|
116
|
+
return content unless content.match?(TOKEN_RE)
|
|
117
|
+
|
|
118
|
+
interpolate(content, fetch_record(context), escape_html: true)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fetch_record(context)
|
|
123
|
+
context.get_record([]) || {}
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
126
|
+
"[forest_admin_datasource_zendesk] failed to fetch record for token interpolation: #{e.class}: #{e.message}"
|
|
127
|
+
)
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Message ships as html_body — unescaped `<` or `&` from a record
|
|
132
|
+
# value would break the outbound email or smuggle markup into it.
|
|
133
|
+
def interpolate(template, record, escape_html:)
|
|
134
|
+
template.gsub(TOKEN_RE) do
|
|
135
|
+
key = ::Regexp.last_match(1)
|
|
136
|
+
value = record[key]
|
|
137
|
+
next '' if value.nil?
|
|
138
|
+
|
|
139
|
+
escape_html ? CGI.escapeHTML(value.to_s) : value.to_s
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def present?(value)
|
|
144
|
+
!value.nil? && value.to_s != ''
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Plugins
|
|
3
|
+
# Zendesk creates the requester user on the fly from the form's email,
|
|
4
|
+
# so the action can be registered on any host collection — no relation
|
|
5
|
+
# to Zendesk needed.
|
|
6
|
+
class CreateTicketWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin
|
|
7
|
+
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
|
|
8
|
+
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope
|
|
9
|
+
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException
|
|
10
|
+
|
|
11
|
+
NAME = 'Create ticket and notify'.freeze
|
|
12
|
+
|
|
13
|
+
def run(_datasource_customizer, collection_customizer = nil, options = {})
|
|
14
|
+
datasource = options[:datasource]
|
|
15
|
+
raise ForestException, 'CreateTicketWithNotification plugin requires :datasource' unless datasource
|
|
16
|
+
raise ForestException, 'CreateTicketWithNotification plugin requires a collection' unless collection_customizer
|
|
17
|
+
|
|
18
|
+
opts = options.except(:datasource)
|
|
19
|
+
opts[:email_templates] = Array(opts[:email_templates]).compact
|
|
20
|
+
collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_action(datasource, opts)
|
|
26
|
+
BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def executor(datasource, opts)
|
|
30
|
+
lambda do |context, result_builder|
|
|
31
|
+
values = context.form_values
|
|
32
|
+
email = values['Requester email']
|
|
33
|
+
next result_builder.error(message: 'Requester email is required.') unless present?(email)
|
|
34
|
+
|
|
35
|
+
payload = build_payload(values, email, opts)
|
|
36
|
+
ticket_id = datasource.client.create_ticket(payload)['id']
|
|
37
|
+
|
|
38
|
+
writeback = write_back_ticket_id(context, opts[:ticket_id_field], ticket_id)
|
|
39
|
+
result_builder.success(message: success_message(ticket_id, values, writeback))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_payload(values, email, opts)
|
|
44
|
+
internal_note = truthy?(values['Send as internal note'])
|
|
45
|
+
payload = {
|
|
46
|
+
# Zendesk's create-user-on-the-fly requires a non-empty `name`;
|
|
47
|
+
# derive from the email's local-part. Ignored if the user exists.
|
|
48
|
+
'requester' => { 'email' => email, 'name' => derive_requester_name(email) },
|
|
49
|
+
'subject' => values['Subject'],
|
|
50
|
+
'comment' => { 'html_body' => values['Message'], 'public' => !internal_note }
|
|
51
|
+
}
|
|
52
|
+
priority = present?(opts[:priority_override]) ? opts[:priority_override] : values['Priority']
|
|
53
|
+
type = present?(opts[:type_override]) ? opts[:type_override] : values['Type']
|
|
54
|
+
payload['priority'] = priority if present?(priority)
|
|
55
|
+
payload['type'] = type if present?(type)
|
|
56
|
+
# Zendesk's `recipient` = the support address replies come FROM.
|
|
57
|
+
payload['recipient'] = opts[:sender_email] if present?(opts[:sender_email])
|
|
58
|
+
payload
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def derive_requester_name(email)
|
|
62
|
+
local = email.to_s.split('@').first.to_s
|
|
63
|
+
local.empty? ? email.to_s : local
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Best-effort: a writeback failure mustn't roll back the Zendesk ticket.
|
|
67
|
+
def write_back_ticket_id(context, field, ticket_id)
|
|
68
|
+
return :skipped if field.nil?
|
|
69
|
+
|
|
70
|
+
context.collection.update(context.filter, { field => ticket_id })
|
|
71
|
+
:ok
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
74
|
+
"[forest_admin_datasource_zendesk] failed to store ticket id in '#{field}': #{e.class}: #{e.message}"
|
|
75
|
+
)
|
|
76
|
+
[:failed, "#{e.class}: #{e.message}"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def success_message(ticket_id, values, writeback = :skipped)
|
|
80
|
+
base = base_success_message(ticket_id, values)
|
|
81
|
+
return base unless writeback.is_a?(Array) && writeback.first == :failed
|
|
82
|
+
|
|
83
|
+
"#{base} (warning: could not store the ticket id on the record: #{writeback.last})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def base_success_message(ticket_id, values)
|
|
87
|
+
if truthy?(values['Send as internal note'])
|
|
88
|
+
"Ticket ##{ticket_id} created (internal note, no email)."
|
|
89
|
+
else
|
|
90
|
+
"Ticket ##{ticket_id} created and requester notified."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def truthy?(value)
|
|
95
|
+
value == true || value.to_s.casecmp('true').zero?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def present?(value)
|
|
99
|
+
!value.nil? && value.to_s != ''
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
# Shared between the Ticket schema and plugins that build ticket forms.
|
|
3
|
+
module TicketEnums
|
|
4
|
+
STATUS = %w[new open pending hold solved closed].freeze
|
|
5
|
+
PRIORITY = %w[low normal high urgent].freeze
|
|
6
|
+
TYPE = %w[problem incident question task].freeze
|
|
7
|
+
end
|
|
8
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forest_admin_datasource_zendesk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Forest Admin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -78,8 +78,14 @@ files:
|
|
|
78
78
|
- lib/forest_admin_datasource_zendesk/collections/user.rb
|
|
79
79
|
- lib/forest_admin_datasource_zendesk/configuration.rb
|
|
80
80
|
- lib/forest_admin_datasource_zendesk/datasource.rb
|
|
81
|
+
- lib/forest_admin_datasource_zendesk/plugins/close_ticket.rb
|
|
82
|
+
- lib/forest_admin_datasource_zendesk/plugins/close_ticket/errors.rb
|
|
83
|
+
- lib/forest_admin_datasource_zendesk/plugins/close_ticket/messages.rb
|
|
84
|
+
- lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb
|
|
85
|
+
- lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification/form_builder.rb
|
|
81
86
|
- lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb
|
|
82
87
|
- lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb
|
|
88
|
+
- lib/forest_admin_datasource_zendesk/ticket_enums.rb
|
|
83
89
|
- lib/forest_admin_datasource_zendesk/version.rb
|
|
84
90
|
homepage: https://www.forestadmin.com
|
|
85
91
|
licenses:
|