forest_admin_datasource_zendesk 1.28.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 +7 -0
- data/.rspec +3 -0
- data/Rakefile +6 -0
- data/forest_admin_datasource_zendesk.gemspec +35 -0
- data/lib/forest_admin_datasource_zendesk/client/introspection.rb +23 -0
- data/lib/forest_admin_datasource_zendesk/client/writes.rb +40 -0
- data/lib/forest_admin_datasource_zendesk/client.rb +118 -0
- data/lib/forest_admin_datasource_zendesk/collections/base_collection.rb +103 -0
- data/lib/forest_admin_datasource_zendesk/collections/organization.rb +118 -0
- data/lib/forest_admin_datasource_zendesk/collections/searchable.rb +38 -0
- data/lib/forest_admin_datasource_zendesk/collections/ticket/comments_embedder.rb +40 -0
- data/lib/forest_admin_datasource_zendesk/collections/ticket/relation_embedder.rb +70 -0
- data/lib/forest_admin_datasource_zendesk/collections/ticket/schema_definition.rb +72 -0
- data/lib/forest_admin_datasource_zendesk/collections/ticket/serializer.rb +31 -0
- data/lib/forest_admin_datasource_zendesk/collections/ticket.rb +128 -0
- data/lib/forest_admin_datasource_zendesk/collections/user.rb +121 -0
- data/lib/forest_admin_datasource_zendesk/configuration.rb +33 -0
- data/lib/forest_admin_datasource_zendesk/datasource.rb +44 -0
- data/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb +122 -0
- data/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb +114 -0
- data/lib/forest_admin_datasource_zendesk/version.rb +3 -0
- data/lib/forest_admin_datasource_zendesk.rb +32 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ad071bda5dc06de2f241d04c2676d8ddf28636a5fd96c6cee30d2390c874c537
|
|
4
|
+
data.tar.gz: 25f249e17a6b7ac578521940112b0918f8a0fb85aa3edbad66bdb59babc0d22e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 163fdfe79cea9ed7189eab4c583a946bd7de6aef5fbc9ffabffe7daede599d459509c8384bfc5fb29af7c160d98747c57a8a8c9e9ab42e76f70db7b469e9667f
|
|
7
|
+
data.tar.gz: 8cdefd1d17454792e5c80d442f262bfdbce5b6a14af9efaeb8b301a9a39f73c0e5732f865b801e81981f1f88129c56bb61b80e8e3b34a86c4dfc2372e749d2ee
|
data/.rspec
ADDED
data/Rakefile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
lib = File.expand_path('lib', __dir__)
|
|
2
|
+
$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
|
|
3
|
+
|
|
4
|
+
require_relative 'lib/forest_admin_datasource_zendesk/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |spec|
|
|
7
|
+
spec.name = 'forest_admin_datasource_zendesk'
|
|
8
|
+
spec.version = ForestAdminDatasourceZendesk::VERSION
|
|
9
|
+
spec.authors = ['Forest Admin']
|
|
10
|
+
spec.email = ['contact@forestadmin.com']
|
|
11
|
+
spec.homepage = 'https://www.forestadmin.com'
|
|
12
|
+
spec.summary = 'Zendesk datasource for Forest Admin Ruby agent.'
|
|
13
|
+
spec.description = 'Surface Zendesk tickets, users, organizations and comments as Forest Admin collections.'
|
|
14
|
+
spec.license = 'GPL-3.0'
|
|
15
|
+
spec.required_ruby_version = '>= 3.0.0'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
|
|
19
|
+
spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
|
|
20
|
+
spec.metadata['rubygems_mfa_required'] = 'false'
|
|
21
|
+
|
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
24
|
+
(File.expand_path(f) == __FILE__) ||
|
|
25
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
spec.bindir = 'exe'
|
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
30
|
+
spec.require_paths = ['lib']
|
|
31
|
+
|
|
32
|
+
spec.add_dependency 'activesupport', '>= 6.1'
|
|
33
|
+
spec.add_dependency 'zeitwerk', '~> 2.3'
|
|
34
|
+
spec.add_dependency 'zendesk_api', '~> 3.0'
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
class Client
|
|
3
|
+
module Introspection
|
|
4
|
+
def fetch_ticket_fields
|
|
5
|
+
best_effort('fetch_ticket_fields (custom fields will be unavailable)', default: []) do
|
|
6
|
+
Array(api.connection.get('ticket_fields').body['ticket_fields'])
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def fetch_user_fields
|
|
11
|
+
best_effort('fetch_user_fields (custom fields will be unavailable)', default: []) do
|
|
12
|
+
Array(api.connection.get('user_fields').body['user_fields'])
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch_organization_fields
|
|
17
|
+
best_effort('fetch_organization_fields (custom fields will be unavailable)', default: []) do
|
|
18
|
+
Array(api.connection.get('organization_fields').body['organization_fields'])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
class Client
|
|
3
|
+
module Writes
|
|
4
|
+
def create_ticket(attributes) = post_resource('tickets', 'ticket', attributes)
|
|
5
|
+
def update_ticket(id, attrs) = put_resource('tickets', 'ticket', id, attrs)
|
|
6
|
+
def delete_ticket(id) = delete_resource('tickets', id)
|
|
7
|
+
|
|
8
|
+
def create_user(attributes) = post_resource('users', 'user', attributes)
|
|
9
|
+
def update_user(id, attrs) = put_resource('users', 'user', id, attrs)
|
|
10
|
+
def delete_user(id) = delete_resource('users', id)
|
|
11
|
+
|
|
12
|
+
def create_organization(attrs) = post_resource('organizations', 'organization', attrs)
|
|
13
|
+
def update_organization(id, attrs) = put_resource('organizations', 'organization', id, attrs)
|
|
14
|
+
def delete_organization(id) = delete_resource('organizations', id)
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
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
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
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
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete_resource(path, id)
|
|
33
|
+
must_succeed("delete(#{path}/#{id})") do
|
|
34
|
+
api.connection.delete("#{path}/#{id}")
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
class Client
|
|
3
|
+
include Writes
|
|
4
|
+
include Introspection
|
|
5
|
+
|
|
6
|
+
MAX_PER_PAGE = 100
|
|
7
|
+
|
|
8
|
+
def initialize(configuration)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def search(type, **opts)
|
|
13
|
+
params = build_search_params(type, opts)
|
|
14
|
+
must_succeed("search(#{type})") { api.search(params).to_a }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def count(type, query:)
|
|
18
|
+
must_succeed("count(#{type})") do
|
|
19
|
+
body = api.connection.get('search/count', query: compose_query(type, query)).body
|
|
20
|
+
Integer(body['count'] || 0)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_ticket_comments(ticket_id)
|
|
25
|
+
must_succeed("fetch_ticket_comments(#{ticket_id})") do
|
|
26
|
+
Array(api.connection.get("tickets/#{ticket_id}/comments").body['comments'])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_ticket(id) = find_one(api.tickets, id)
|
|
31
|
+
def find_user(id) = find_one(api.users, id)
|
|
32
|
+
def find_organization(id) = find_one(api.organizations, id)
|
|
33
|
+
|
|
34
|
+
def fetch_user_emails(ids)
|
|
35
|
+
best_effort('fetch_user_emails', default: {}) do
|
|
36
|
+
bulk_show_many('users', ids) { |u| [u['id'], u['email']] }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch_tickets_by_ids(ids)
|
|
41
|
+
must_succeed('fetch_tickets_by_ids') do
|
|
42
|
+
bulk_show_many('tickets', ids) { |t| [t['id'], t] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch_users_by_ids(ids)
|
|
47
|
+
best_effort('fetch_users_by_ids', default: {}) do
|
|
48
|
+
bulk_show_many('users', ids) { |u| [u['id'], u] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_organizations_by_ids(ids)
|
|
53
|
+
best_effort('fetch_organizations_by_ids', default: {}) do
|
|
54
|
+
bulk_show_many('organizations', ids) { |o| [o['id'], o] }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def find_one(api_collection, id)
|
|
61
|
+
api_collection.find(id: id)
|
|
62
|
+
rescue ZendeskAPI::Error::RecordNotFound
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def bulk_show_many(resource, ids)
|
|
67
|
+
ids = Array(ids).compact.uniq
|
|
68
|
+
return {} if ids.empty?
|
|
69
|
+
|
|
70
|
+
ids.each_slice(MAX_PER_PAGE).with_object({}) do |batch, acc|
|
|
71
|
+
body = api.connection.get("#{resource}/show_many", ids: batch.join(',')).body
|
|
72
|
+
Array(body[resource]).each do |item|
|
|
73
|
+
k, v = yield(item)
|
|
74
|
+
acc[k] = v
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def must_succeed(operation)
|
|
80
|
+
yield
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
raise APIError, "Zendesk API call failed: #{operation}: #{e.class}: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def best_effort(operation, default:)
|
|
86
|
+
yield
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
89
|
+
"[forest_admin_datasource_zendesk] #{operation} failed; degrading: #{e.class}: #{e.message}"
|
|
90
|
+
)
|
|
91
|
+
default
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def compose_query(type, query)
|
|
95
|
+
[type ? "type:#{type}" : nil, query.to_s.strip].compact.reject(&:empty?).join(' ')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_search_params(type, opts)
|
|
99
|
+
params = {
|
|
100
|
+
query: compose_query(type, opts[:query]),
|
|
101
|
+
per_page: [opts[:per_page] || MAX_PER_PAGE, MAX_PER_PAGE].min,
|
|
102
|
+
page: opts[:page] || 1
|
|
103
|
+
}
|
|
104
|
+
params[:sort_by] = opts[:sort_by] if opts[:sort_by]
|
|
105
|
+
params[:sort_order] = opts[:sort_order] if opts[:sort_order]
|
|
106
|
+
params
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def api
|
|
110
|
+
@api ||= ZendeskAPI::Client.new do |c|
|
|
111
|
+
c.url = @configuration.url
|
|
112
|
+
c.username = @configuration.username
|
|
113
|
+
c.token = @configuration.token
|
|
114
|
+
c.retry = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class BaseCollection < ForestAdminDatasourceToolkit::Collection
|
|
4
|
+
ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
|
|
5
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
6
|
+
Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
|
|
7
|
+
|
|
8
|
+
STRING_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN,
|
|
9
|
+
Operators::PRESENT, Operators::BLANK].freeze
|
|
10
|
+
NUMBER_OPS = (STRING_OPS + [Operators::GREATER_THAN, Operators::LESS_THAN]).freeze
|
|
11
|
+
DATE_OPS = [Operators::EQUAL, Operators::BEFORE, Operators::AFTER,
|
|
12
|
+
Operators::PRESENT, Operators::BLANK].freeze
|
|
13
|
+
|
|
14
|
+
def aggregate(caller, filter, aggregation, _limit = nil)
|
|
15
|
+
unless aggregation.operation == 'Count' && aggregation.field.nil? && aggregation.groups.empty?
|
|
16
|
+
raise ForestAdminDatasourceToolkit::Exceptions::ForestException,
|
|
17
|
+
'Zendesk datasource only supports Count aggregation without groups.'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
[{ 'value' => aggregate_count(caller, filter), 'group' => {} }]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def aggregate_count(_caller, _filter)
|
|
26
|
+
raise NotImplementedError, "#{self.class} did not implement aggregate_count"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Zendesk Search has no `id:` operator, so collections short-circuit
|
|
30
|
+
# PK lookups to /resource/{id} when the filter is `id = N` or `id IN [...]`.
|
|
31
|
+
def extract_id_lookup(node)
|
|
32
|
+
return nil unless node.is_a?(Leaf) && node.field == 'id'
|
|
33
|
+
|
|
34
|
+
case node.operator
|
|
35
|
+
when Operators::EQUAL then [node.value]
|
|
36
|
+
when Operators::IN then Array(node.value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def project(record, projection)
|
|
41
|
+
return record if projection.nil?
|
|
42
|
+
|
|
43
|
+
wanted = Array(projection).map(&:to_s).reject { |p| p.include?(':') }
|
|
44
|
+
return record if wanted.empty?
|
|
45
|
+
|
|
46
|
+
wanted.to_h { |k| [k, record[k]] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Unknown fields silently disable sorting — Zendesk's Search API only
|
|
50
|
+
# honours a fixed allow-list per resource.
|
|
51
|
+
def translate_sort(sort, allow_list)
|
|
52
|
+
return [nil, nil] if sort.nil? || sort.empty?
|
|
53
|
+
|
|
54
|
+
field, ascending = sort_field_and_direction(sort.first)
|
|
55
|
+
zd_field = allow_list[field.to_s]
|
|
56
|
+
return [nil, nil] unless zd_field
|
|
57
|
+
|
|
58
|
+
[zd_field, ascending ? 'asc' : 'desc']
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def translate_page(page)
|
|
62
|
+
return [1, Client::MAX_PER_PAGE] if page.nil?
|
|
63
|
+
|
|
64
|
+
per_page = page.limit&.positive? ? [page.limit, Client::MAX_PER_PAGE].min : Client::MAX_PER_PAGE
|
|
65
|
+
page_num = (page.offset.to_i / per_page) + 1
|
|
66
|
+
[page_num, per_page]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attrs_of(record)
|
|
70
|
+
record.respond_to?(:attributes) ? record.attributes : record.to_h
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ids_for(caller, filter)
|
|
74
|
+
list(caller, filter, ['id']).filter_map { |row| row['id'] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def timezone_for(caller)
|
|
78
|
+
return 'UTC' unless caller.respond_to?(:timezone)
|
|
79
|
+
|
|
80
|
+
tz = caller.timezone
|
|
81
|
+
tz.nil? || tz.empty? ? 'UTC' : tz
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_zendesk_query(caller, filter)
|
|
85
|
+
translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call(
|
|
86
|
+
filter.condition_tree, timezone: timezone_for(caller),
|
|
87
|
+
custom_fields: datasource.custom_field_mapping
|
|
88
|
+
)
|
|
89
|
+
[translated, filter.search].compact.reject(&:empty?).join(' ')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def sort_field_and_direction(entry)
|
|
95
|
+
return [entry.field, entry.ascending] if entry.respond_to?(:field)
|
|
96
|
+
|
|
97
|
+
field = entry.key?(:field) ? entry[:field] : entry['field']
|
|
98
|
+
ascending = entry.key?(:ascending) ? entry[:ascending] : entry['ascending']
|
|
99
|
+
[field, ascending]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Organization < BaseCollection
|
|
4
|
+
include Searchable
|
|
5
|
+
|
|
6
|
+
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
|
|
7
|
+
|
|
8
|
+
ZENDESK_SORTABLE = {
|
|
9
|
+
'created_at' => 'created_at',
|
|
10
|
+
'updated_at' => 'updated_at',
|
|
11
|
+
'name' => 'name'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(datasource, custom_fields: [])
|
|
15
|
+
super(datasource, 'ZendeskOrganization')
|
|
16
|
+
@custom_fields = custom_fields
|
|
17
|
+
define_schema
|
|
18
|
+
define_relations
|
|
19
|
+
enable_search
|
|
20
|
+
enable_count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create(_caller, data)
|
|
24
|
+
payload = build_payload(data)
|
|
25
|
+
created = datasource.client.create_organization(payload)
|
|
26
|
+
serialize(created)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update(caller, filter, patch)
|
|
30
|
+
ids = ids_for(caller, filter)
|
|
31
|
+
payload = build_payload(patch)
|
|
32
|
+
ids.each { |id| datasource.client.update_organization(id, payload) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(caller, filter)
|
|
36
|
+
ids_for(caller, filter).each { |id| datasource.client.delete_organization(id) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
def zendesk_resource = 'organization'
|
|
42
|
+
def sortable_fields = ZENDESK_SORTABLE
|
|
43
|
+
def find_one(id) = datasource.client.find_organization(id)
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_payload(data)
|
|
48
|
+
attrs = data.transform_keys(&:to_s)
|
|
49
|
+
cf_keys = @custom_fields.to_h { |cf| [cf[:column_name], cf[:zendesk_key]] }
|
|
50
|
+
org_fields = {}
|
|
51
|
+
base = attrs.each_with_object({}) do |(k, v), h|
|
|
52
|
+
if (key = cf_keys[k])
|
|
53
|
+
org_fields[key] = v
|
|
54
|
+
else
|
|
55
|
+
h[k] = v
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
%w[id created_at updated_at].each { |k| base.delete(k) }
|
|
59
|
+
base['organization_fields'] = org_fields unless org_fields.empty?
|
|
60
|
+
base
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def define_schema
|
|
64
|
+
add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
65
|
+
is_primary_key: true, is_read_only: true, is_sortable: true))
|
|
66
|
+
add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
67
|
+
is_read_only: false, is_sortable: true))
|
|
68
|
+
add_field('domain_names', ColumnSchema.new(column_type: 'Json', filter_operators: [],
|
|
69
|
+
is_read_only: false, is_sortable: false))
|
|
70
|
+
add_field('details', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
71
|
+
is_read_only: false, is_sortable: false))
|
|
72
|
+
add_field('notes', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
73
|
+
is_read_only: false, is_sortable: false))
|
|
74
|
+
add_field('group_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
75
|
+
is_read_only: false, is_sortable: false))
|
|
76
|
+
add_field('shared_tickets', ColumnSchema.new(column_type: 'Boolean', filter_operators: [],
|
|
77
|
+
is_read_only: false, is_sortable: false))
|
|
78
|
+
add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
79
|
+
is_read_only: true, is_sortable: true))
|
|
80
|
+
add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
81
|
+
is_read_only: true, is_sortable: true))
|
|
82
|
+
|
|
83
|
+
@custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def define_relations
|
|
87
|
+
add_field('users', OneToManySchema.new(
|
|
88
|
+
foreign_collection: 'ZendeskUser',
|
|
89
|
+
origin_key: 'organization_id',
|
|
90
|
+
origin_key_target: 'id'
|
|
91
|
+
))
|
|
92
|
+
add_field('tickets', OneToManySchema.new(
|
|
93
|
+
foreign_collection: 'ZendeskTicket',
|
|
94
|
+
origin_key: 'organization_id',
|
|
95
|
+
origin_key_target: 'id'
|
|
96
|
+
))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def serialize(org)
|
|
100
|
+
attrs = attrs_of(org)
|
|
101
|
+
result = base_attributes(attrs)
|
|
102
|
+
org_fields = attrs['organization_fields'] || {}
|
|
103
|
+
@custom_fields.each { |cf| result[cf[:column_name]] = org_fields[cf[:zendesk_key]] }
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def base_attributes(attrs)
|
|
108
|
+
{
|
|
109
|
+
'id' => attrs['id'], 'name' => attrs['name'],
|
|
110
|
+
'domain_names' => attrs['domain_names'], 'details' => attrs['details'],
|
|
111
|
+
'notes' => attrs['notes'], 'group_id' => attrs['group_id'],
|
|
112
|
+
'shared_tickets' => attrs['shared_tickets'],
|
|
113
|
+
'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at']
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
# Including classes must define `zendesk_resource`, `find_one(id)`,
|
|
4
|
+
# `sortable_fields`, and `serialize(record)`.
|
|
5
|
+
module Searchable
|
|
6
|
+
def list(caller, filter, projection)
|
|
7
|
+
records = ids_in_filter(filter) ? find_records_by_id(filter) : search_records(caller, filter)
|
|
8
|
+
records.map { |r| project(serialize(r), projection) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def aggregate_count(caller, filter)
|
|
14
|
+
datasource.client.count(zendesk_resource, query: build_zendesk_query(caller, filter))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def ids_in_filter(filter)
|
|
20
|
+
extract_id_lookup(filter.condition_tree)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_records_by_id(filter)
|
|
24
|
+
ids_in_filter(filter).filter_map { |id| find_one(id) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def search_records(caller, filter)
|
|
28
|
+
sort_by, sort_order = translate_sort(filter.sort, sortable_fields)
|
|
29
|
+
page, per_page = translate_page(filter.page)
|
|
30
|
+
|
|
31
|
+
datasource.client.search(zendesk_resource,
|
|
32
|
+
query: build_zendesk_query(caller, filter),
|
|
33
|
+
sort_by: sort_by, sort_order: sort_order,
|
|
34
|
+
page: page, per_page: per_page)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < BaseCollection
|
|
4
|
+
module CommentsEmbedder
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def want_comments?(projection)
|
|
8
|
+
projection.nil? ||
|
|
9
|
+
Array(projection).map(&:to_s).any? { |p| p == 'comments' || p.start_with?('comments:') }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def embed_comments(records, rows)
|
|
13
|
+
comments_by_ticket = records.to_h do |t|
|
|
14
|
+
id = attrs_of(t)['id']
|
|
15
|
+
[id, datasource.client.fetch_ticket_comments(id)]
|
|
16
|
+
end
|
|
17
|
+
author_ids = comments_by_ticket.values.flatten.filter_map { |c| c['author_id'] }.uniq
|
|
18
|
+
users = datasource.client.fetch_users_by_ids(author_ids)
|
|
19
|
+
rows.each_with_index do |row, i|
|
|
20
|
+
row['comments'] = comments_by_ticket[attrs_of(records[i])['id']].map { |c| serialize_comment(c, users) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def serialize_comment(comment, users)
|
|
25
|
+
author = users[comment['author_id']]
|
|
26
|
+
author_attrs = author && (author.is_a?(Hash) ? author : attrs_of(author))
|
|
27
|
+
{
|
|
28
|
+
'id' => comment['id'],
|
|
29
|
+
'body' => comment['body'],
|
|
30
|
+
'html_body' => comment['html_body'],
|
|
31
|
+
'public' => comment['public'],
|
|
32
|
+
'author_email' => author_attrs && author_attrs['email'],
|
|
33
|
+
'author_name' => author_attrs && author_attrs['name'],
|
|
34
|
+
'created_at' => comment['created_at']
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < BaseCollection
|
|
4
|
+
# Bulk-loads requester/assignee/organization records from the source
|
|
5
|
+
# tickets and writes them back onto the projected rows by index.
|
|
6
|
+
module RelationEmbedder
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def embed_relations(records, rows, projection)
|
|
10
|
+
return if projection.nil?
|
|
11
|
+
|
|
12
|
+
relations = relations_in(projection)
|
|
13
|
+
return if relations.empty?
|
|
14
|
+
|
|
15
|
+
sources = records.map { |t| attrs_of(t) }
|
|
16
|
+
embed_users(rows, sources, relations) if (relations & %w[requester assignee]).any?
|
|
17
|
+
embed_organizations(rows, sources) if relations.include?('organization')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def embed_users(rows, sources, relations)
|
|
21
|
+
ids = sources.flat_map { |a| [a['requester_id'], a['assignee_id']] }.compact.uniq
|
|
22
|
+
users = datasource.client.fetch_users_by_ids(ids)
|
|
23
|
+
rows.each_with_index do |row, i|
|
|
24
|
+
row['requester'] = serialized_user(users[sources[i]['requester_id']]) if relations.include?('requester')
|
|
25
|
+
row['assignee'] = serialized_user(users[sources[i]['assignee_id']]) if relations.include?('assignee')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def embed_organizations(rows, sources)
|
|
30
|
+
ids = sources.filter_map { |a| a['organization_id'] }.uniq
|
|
31
|
+
orgs = datasource.client.fetch_organizations_by_ids(ids)
|
|
32
|
+
rows.each_with_index do |row, i|
|
|
33
|
+
row['organization'] = serialized_org(orgs[sources[i]['organization_id']])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def relations_in(projection)
|
|
38
|
+
Array(projection).map(&:to_s).filter_map { |p| p.split(':').first if p.include?(':') }.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialized_user(raw)
|
|
42
|
+
return nil if raw.nil?
|
|
43
|
+
|
|
44
|
+
attrs = raw.is_a?(Hash) ? raw : attrs_of(raw)
|
|
45
|
+
{
|
|
46
|
+
'id' => attrs['id'], 'email' => attrs['email'], 'name' => attrs['name'],
|
|
47
|
+
'role' => attrs['role'], 'organization_id' => attrs['organization_id'],
|
|
48
|
+
'phone' => attrs['phone'], 'time_zone' => attrs['time_zone'],
|
|
49
|
+
'locale' => attrs['locale'], 'verified' => attrs['verified'],
|
|
50
|
+
'suspended' => attrs['suspended'], 'created_at' => attrs['created_at'],
|
|
51
|
+
'updated_at' => attrs['updated_at']
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def serialized_org(raw)
|
|
56
|
+
return nil if raw.nil?
|
|
57
|
+
|
|
58
|
+
attrs = raw.is_a?(Hash) ? raw : attrs_of(raw)
|
|
59
|
+
{
|
|
60
|
+
'id' => attrs['id'], 'name' => attrs['name'],
|
|
61
|
+
'domain_names' => attrs['domain_names'], 'details' => attrs['details'],
|
|
62
|
+
'notes' => attrs['notes'], 'group_id' => attrs['group_id'],
|
|
63
|
+
'shared_tickets' => attrs['shared_tickets'],
|
|
64
|
+
'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at']
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|