forest_admin_datasource_zendesk 1.29.2 → 1.30.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0760d5ef358744a243f766144ffe95589066dd1e0b02da42ab3079dea96d5a2f
4
- data.tar.gz: 97bfcb9aa7742bc7e4f0908d976a1881664ac38723f93f0c0570df885dce2e2d
3
+ metadata.gz: 84b08e9f27307b2c63ac820be8b0a8c29d23272837bfc629111e66f5d06fd720
4
+ data.tar.gz: 997953ff0ca7596931d90f7eb48e53b8f010632822c5ad5abe2ff0c6e79fe609
5
5
  SHA512:
6
- metadata.gz: a1b4934f135351ddeabdf5ca15c9aa2199d55e0dfba5b90c165f5510bbb5f217674c1aba3f72f4cb17454b1a03e76e5a367cc099843bc0ed961542d3b47cc5e1
7
- data.tar.gz: 3bdf4e7f612ad7f9cb54622bfeac44e24ad7d0f0bc451d4805b8e1ab3ced56dc6885e1add343e6824a0dd5dde96782ed72bf2271eee6de2a0de9559dcde8b596
6
+ metadata.gz: b3dd9d667a4bcd0bed95823923ae71ad05ad9b628fc9bd34d0b362ab352fda498c77e3863bf74e45e5a1b105ae7eae1abf0284c0c3be4f16cd0e286ae2daf2fd
7
+ data.tar.gz: a044e0316cf61345d51d024214b394f66c503e69b01e6a473a07b16837cfc1d4ba0b696ce496aeb35cdf707d01b13d694062c9556cbf34ad08c2732fade9b3d6
@@ -16,17 +16,30 @@ module ForestAdminDatasourceZendesk
16
16
  private
17
17
 
18
18
  def post_resource(path, key, attributes)
19
- must_succeed("create(#{path})") do
20
- body = api.connection.post(path) { |req| req.body = { key => attributes } }.body
21
- body[key] || 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
- must_succeed("update(#{path}/#{id})") do
27
- body = api.connection.put("#{path}/#{id}") { |req| req.body = { key => attributes } }.body
28
- body[key] || 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: ENUM_STATUS, is_read_only: false, is_sortable: true))
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: ENUM_PRIORITY, is_read_only: false, is_sortable: true))
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: ENUM_TYPE, is_read_only: false, is_sortable: true))
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 = base_attributes(attrs)
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceZendesk
2
- VERSION = "1.29.2"
2
+ VERSION = "1.30.1"
3
3
  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.29.2
4
+ version: 1.30.1
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-13 00:00:00.000000000 Z
11
+ date: 2026-05-20 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: