Skip to content
Merged
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 @@ -16,17 +16,30 @@ def delete_organization(id) = delete_resource('organizations', id)
private

def post_resource(path, key, attributes)
must_succeed("create(#{path})") do
body = api.connection.post(path) { |req| req.body = { key => attributes } }.body
body[key] || body
op = "create(#{path})"
body = must_succeed(op) do
api.connection.post(path) { |req| req.body = { key => attributes } }.body
end
extract_resource(body, key, op)
end

def put_resource(path, key, id, attributes)
must_succeed("update(#{path}/#{id})") do
body = api.connection.put("#{path}/#{id}") { |req| req.body = { key => attributes } }.body
body[key] || body
op = "update(#{path}/#{id})"
body = must_succeed(op) do
api.connection.put("#{path}/#{id}") { |req| req.body = { key => attributes } }.body
end
extract_resource(body, key, op)
end

# Zendesk wraps create/update responses in `{ "<resource>": { ... } }`.
# An empty or differently-shaped body means the API contract broke —
# surface a typed error rather than handing back a confusing envelope.
def extract_resource(body, key, operation)
resource = body[key] if body.is_a?(Hash)
return resource if resource.is_a?(Hash)

raise APIError,
"Zendesk API #{operation} returned an unexpected body shape (missing '#{key}'): #{body.inspect}"
end

def delete_resource(path, id)
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,123 @@
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
ForestException = ForestAdminDatasourceToolkit::Exceptions::ForestException

STATUSES = %w[solved closed].freeze
SCOPE_KEYS = %i[single bulk].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 ForestException, 'CloseTicket plugin requires :datasource' unless datasource
raise ForestException, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field
raise ForestException, '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)
normalize(value, :to_s, STATUSES, 'statuses')
end

def normalize_scopes(value)
normalize(value, :to_sym, SCOPE_KEYS, 'scopes')
end

def normalize(value, cast, allowed, label)
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): normalize [qlty:function-parameters]

list = Array(value).map(&cast).uniq
list = allowed if list.empty?
unknown = list - allowed
return list if unknown.empty?

raise ForestException,
"Unknown CloseTicket #{label}: #{unknown.join(", ")}. Allowed: #{allowed.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.filter_map { |r| r[ticket_id_field.to_s] }
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 Errors.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
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module ForestAdminDatasourceZendesk
module Plugins
class CloseTicket
# Decoding helpers for Zendesk's structured update-error payloads.
module Errors
module_function

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

def already_closed?(error)
invalid = unwrap_record_invalid(error)
return false unless invalid

status_errors = invalid.errors.is_a?(Hash) ? Array(invalid.errors['status']) : []
status_errors.any? do |entry|
entry.is_a?(Hash) && entry['description'].to_s.include?(ALREADY_CLOSED_DESCRIPTION)
end
end

def unwrap_record_invalid(error)
while error
return error if error.is_a?(ZendeskAPI::Error::RecordInvalid)

error = error.cause
end
nil
end
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
Loading
Loading