Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/forest_admin_datasource_zendesk/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ source 'https://rubygems.org'

gemspec

gem 'forest_admin_datasource_customizer'
gem 'forest_admin_datasource_toolkit'
gem 'rake', '~> 13.0'
gem 'rubocop', '1.86.1'
Expand Down
1 change: 1 addition & 0 deletions packages/forest_admin_datasource_zendesk/Gemfile-test
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ gem 'rubocop-performance', '1.26.1'
gem 'rubocop-rspec', '3.9.0'

group :development, :test do
gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer'
gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit'
gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ class Ticket < BaseCollection

ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema

ENUM_STATUS = %w[new open pending hold solved closed].freeze
ENUM_PRIORITY = %w[low normal high urgent].freeze
ENUM_TYPE = %w[problem incident question task].freeze

ZENDESK_SORTABLE = {
'updated_at' => 'updated_at',
'created_at' => 'created_at',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ def define_schema
add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: [],
is_read_only: false, is_sortable: false))
add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
enum_values: ENUM_STATUS, is_read_only: false, is_sortable: true))
enum_values: TicketEnums::STATUS, is_read_only: false,
is_sortable: true))
add_field('priority', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
enum_values: ENUM_PRIORITY, is_read_only: false, is_sortable: true))
enum_values: TicketEnums::PRIORITY, is_read_only: false,
is_sortable: true))
add_field('ticket_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
enum_values: ENUM_TYPE, is_read_only: false, is_sortable: true))
enum_values: TicketEnums::TYPE, is_read_only: false,
is_sortable: true))
add_field('requester_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
is_read_only: false, is_sortable: true))
add_field('assignee_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class User < BaseCollection
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
ENUM_ROLE = %w[end-user agent admin].freeze
BASE_ATTR_KEYS = %w[id email name role phone organization_id time_zone locale verified suspended
created_at updated_at].freeze

ZENDESK_SORTABLE = {
'created_at' => 'created_at',
Expand Down Expand Up @@ -100,22 +102,11 @@ def define_relations

def serialize(user)
attrs = attrs_of(user)
result = base_attributes(attrs)
result = BASE_ATTR_KEYS.to_h { |k| [k, attrs[k]] }
user_fields = attrs['user_fields'] || {}
@custom_fields.each { |cf| result[cf[:column_name]] = user_fields[cf[:zendesk_key]] }
result
end

def base_attributes(attrs)
{
'id' => attrs['id'], 'email' => attrs['email'], 'name' => attrs['name'],
'role' => attrs['role'], 'phone' => attrs['phone'],
'organization_id' => attrs['organization_id'],
'time_zone' => attrs['time_zone'], 'locale' => attrs['locale'],
'verified' => attrs['verified'], 'suspended' => attrs['suspended'],
'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at']
}
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
module ForestAdminDatasourceZendesk
module Plugins
# The Zendesk ticket id is read from a configurable column on the host
# record(s); Zendesk sometimes rejects the direct `open -> closed`
# transition so failures are surfaced per-id rather than retried.
class CloseTicket < ForestAdminDatasourceCustomizer::Plugins::Plugin
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope

STATUSES = %w[solved closed].freeze
SCOPE_KEYS = %i[single bulk].freeze

# Zendesk refuses any update on a closed ticket with this exact
# wording — detected so we can swap the raw stack for a clean message.
ALREADY_CLOSED_PATTERN = 'closed prevents ticket update'.freeze

NAMES = {
'solved' => { single: 'Mark Zendesk ticket as solved',
bulk: 'Mark selected Zendesk tickets as solved' }.freeze,
'closed' => { single: 'Mark Zendesk ticket as closed',
bulk: 'Mark selected Zendesk tickets as closed' }.freeze
}.freeze

SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze

def run(_datasource_customizer, collection_customizer = nil, options = {})
datasource = options[:datasource]
ticket_id_field = options[:ticket_id_field]
raise ArgumentError, 'CloseTicket plugin requires :datasource' unless datasource
raise ArgumentError, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field
raise ArgumentError, 'CloseTicket plugin requires a collection' unless collection_customizer

statuses = normalize_statuses(options[:statuses])
scopes = normalize_scopes(options[:scopes])

variants(statuses, scopes).each do |name, status, scope|
collection_customizer.add_action(name, build_action(datasource, status, scope, ticket_id_field))
end
end

private

def normalize_statuses(value)
list = Array(value).map(&:to_s).uniq
list = STATUSES if list.empty?
unknown = list - STATUSES
return list if unknown.empty?

raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
"Unknown CloseTicket statuses: #{unknown.join(", ")}. Allowed: #{STATUSES.join(", ")}."
end

def normalize_scopes(value)
list = Array(value).map(&:to_sym).uniq
list = SCOPE_KEYS if list.empty?
unknown = list - SCOPE_KEYS
return list if unknown.empty?

raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
"Unknown CloseTicket scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}."
end

def variants(statuses, scopes)
statuses.flat_map do |status|
scopes.map { |scope_key| [NAMES[status][scope_key], status, SCOPES[scope_key]] }
end
end

def build_action(datasource, status, scope, ticket_id_field)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): build_action [qlty:function-parameters]

BaseAction.new(scope: scope, &executor(datasource, status, ticket_id_field))
end

def executor(datasource, status, ticket_id_field)
lambda do |context, result_builder|
ids = resolve_ticket_ids(context, ticket_id_field)
next result_builder.error(message: "No Zendesk ticket id found in '#{ticket_id_field}'.") if ids.empty?

succeeded, already_closed, failed = apply_status(datasource, ids, status)

# Closed tickets can't be reopened to 'solved'; fold into failures.
if status == 'solved'
failed += already_closed.map { |id| [id, 'ticket is already closed (cannot reopen to mark as solved)'] }
already_closed = []
end

if succeeded.empty? && already_closed.empty?
result_builder.error(message: Messages.error(failed, status))
else
result_builder.success(message: Messages.success(succeeded, already_closed, failed, status))
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 10): executor [qlty:function-complexity]

end

def resolve_ticket_ids(context, ticket_id_field)
records = context.get_records([ticket_id_field])
records = [records].compact unless records.is_a?(Array)
records.filter_map { |r| r[ticket_id_field] || r[ticket_id_field.to_sym] }
rescue StandardError => e
ForestAdminDatasourceZendesk.logger.warn(
"[forest_admin_datasource_zendesk] failed to resolve ticket ids from '#{ticket_id_field}': " \
"#{e.class}: #{e.message}"
)
[]
end

# Per-id rescue so a single transition rejection doesn't abort bulk.
def apply_status(datasource, ids, status)
succeeded = []
already_closed = []
failed = []
ids.each do |id|
datasource.client.update_ticket(id, 'status' => status)
succeeded << id
rescue StandardError => e
if already_closed?(e)
already_closed << id
else
ForestAdminDatasourceZendesk.logger.warn(
"[forest_admin_datasource_zendesk] failed to set ticket ##{id} to '#{status}': " \
"#{e.class}: #{e.message}"
)
failed << [id, "#{e.class}: #{e.message}"]
end
end
[succeeded, already_closed, failed]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 6): apply_status [qlty:function-complexity]

end

def already_closed?(error)
error.message.to_s.include?(ALREADY_CLOSED_PATTERN)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module ForestAdminDatasourceZendesk
module Plugins
class CloseTicket
module Messages
module_function

def success(succeeded, already_closed, failed, status)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): success [qlty:function-parameters]

[succeeded_phrase(succeeded, status), already_closed_phrase(already_closed),
failed_phrase(failed)].compact.join(' ')
end

def error(failed, status)
verb = status == 'closed' ? 'close' : 'mark as solved'
return "Failed to #{verb} ticket ##{failed.first.first}: #{failed.first.last}" if failed.size == 1

"Failed to #{verb} all #{failed.size} tickets. First error: #{failed.first.last}"
end

def succeeded_phrase(succeeded, status)
return nil if succeeded.empty?

verb = status == 'closed' ? 'closed' : 'marked as solved'
succeeded.size == 1 ? "Ticket ##{succeeded.first} #{verb}." : "#{succeeded.size} tickets #{verb}."
end

def already_closed_phrase(already_closed)
return nil if already_closed.empty?
return "Ticket ##{already_closed.first} was already closed." if already_closed.size == 1

"#{already_closed.size} tickets were already closed: #{already_closed.join(", ")}."
end

def failed_phrase(failed)
return nil if failed.empty?

"#{failed.size} failed: #{failed.map(&:first).join(", ")}."
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
module ForestAdminDatasourceZendesk
module Plugins
# Zendesk creates the requester user on the fly from the form's email,
# so the action can be registered on any host collection — no relation
# to Zendesk needed.
class CreateTicketWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin
BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction
ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope

NAME = 'Create ticket and notify'.freeze

def run(_datasource_customizer, collection_customizer = nil, options = {})
datasource = options[:datasource]
raise ArgumentError, 'CreateTicketWithNotification plugin requires :datasource' unless datasource
raise ArgumentError, 'CreateTicketWithNotification plugin requires a collection' unless collection_customizer

opts = options.except(:datasource)
opts[:email_templates] = Array(opts[:email_templates]).compact
collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts))
end

private

def build_action(datasource, opts)
BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts))
end

def executor(datasource, opts)
lambda do |context, result_builder|
values = context.form_values
email = values['Requester email']
next result_builder.error(message: 'Requester email is required.') unless present?(email)

payload = build_payload(values, email, opts)
ticket = datasource.client.create_ticket(payload)
ticket_id = ticket.respond_to?(:[]) ? ticket['id'] : nil

writeback = write_back_ticket_id(context, opts[:ticket_id_field], ticket_id)
result_builder.success(message: success_message(ticket_id, values, writeback))
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): executor [qlty:function-complexity]

end

def build_payload(values, email, opts)
internal_note = truthy?(values['Send as internal note'])
payload = {
# Zendesk's create-user-on-the-fly requires a non-empty `name`;
# derive from the email's local-part. Ignored if the user exists.
'requester' => { 'email' => email, 'name' => derive_requester_name(email) },
'subject' => values['Subject'],
'comment' => { 'html_body' => values['Message'], 'public' => !internal_note }
}
priority = present?(opts[:priority_override]) ? opts[:priority_override] : values['Priority']
type = present?(opts[:type_override]) ? opts[:type_override] : values['Type']
payload['priority'] = priority if present?(priority)
payload['type'] = type if present?(type)
# Zendesk's `recipient` = the support address replies come FROM.
payload['recipient'] = opts[:sender_email] if present?(opts[:sender_email])
payload
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): build_payload [qlty:function-complexity]

end

def derive_requester_name(email)
local = email.to_s.split('@').first.to_s
local.empty? ? email.to_s : local
end

# Best-effort: a writeback failure mustn't roll back the Zendesk ticket.
def write_back_ticket_id(context, field, ticket_id)
return :skipped if field.nil? || ticket_id.nil?

context.collection.update(context.filter, { field => ticket_id })
:ok
rescue StandardError => e
ForestAdminDatasourceZendesk.logger.warn(
"[forest_admin_datasource_zendesk] failed to store ticket id in '#{field}': #{e.class}: #{e.message}"
)
[:failed, "#{e.class}: #{e.message}"]
end

def success_message(ticket_id, values, writeback = :skipped)
base = base_success_message(ticket_id, values)
return base unless writeback.is_a?(Array) && writeback.first == :failed

"#{base} (warning: could not store the ticket id on the record: #{writeback.last})"
end

def base_success_message(ticket_id, values)
if truthy?(values['Send as internal note'])
return 'Ticket created (internal note).' unless ticket_id

"Ticket ##{ticket_id} created (internal note, no email)."
else
return 'Ticket created and requester notified.' unless ticket_id

"Ticket ##{ticket_id} created and requester notified."
end
end

def truthy?(value)
value == true || value.to_s.casecmp('true').zero?
end

def present?(value)
!value.nil? && value.to_s != ''
end
Comment on lines +102 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low plugins/create_ticket_with_notification.rb:102

The present? helper considers whitespace-only strings as present (" ".present? is true), so an email field containing only spaces passes validation at line 32. The code then calls create_ticket with an invalid whitespace-only email, which likely causes Zendesk API errors or creates malformed tickets. Consider stripping whitespace before the empty check, or using a stricter validation that rejects whitespace-only input.

      def present?(value)
-        !value.nil? && value.to_s != ''
+        !value.nil? && value.to_s.strip != ''
      end
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb around lines 102-104:

The `present?` helper considers whitespace-only strings as present (`"   ".present?` is true), so an email field containing only spaces passes validation at line 32. The code then calls `create_ticket` with an invalid whitespace-only email, which likely causes Zendesk API errors or creates malformed tickets. Consider stripping whitespace before the empty check, or using a stricter validation that rejects whitespace-only input.

Evidence trail:
packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/plugins/create_ticket_with_notification.rb lines 32 and 102 at REVIEWED_COMMIT. Custom `present?` defined at line 102 checks `!value.nil? && value.to_s != ''` which returns true for whitespace-only strings. Line 32 uses this method to validate email before passing it to `create_ticket` at line 35.

end
end
end
Loading
Loading