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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < BaseCollection
|
|
4
|
+
module SchemaDefinition
|
|
5
|
+
ColumnSchema = BaseCollection::ColumnSchema
|
|
6
|
+
Operators = BaseCollection::Operators
|
|
7
|
+
STRING_OPS = BaseCollection::STRING_OPS
|
|
8
|
+
NUMBER_OPS = BaseCollection::NUMBER_OPS
|
|
9
|
+
DATE_OPS = BaseCollection::DATE_OPS
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def define_schema
|
|
14
|
+
add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
15
|
+
is_primary_key: true, is_read_only: true, is_sortable: true))
|
|
16
|
+
add_field('subject', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
17
|
+
is_read_only: false, is_sortable: false))
|
|
18
|
+
add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
19
|
+
is_read_only: false, is_sortable: false))
|
|
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))
|
|
22
|
+
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
|
+
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))
|
|
26
|
+
add_field('requester_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
27
|
+
is_read_only: false, is_sortable: true))
|
|
28
|
+
add_field('assignee_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
29
|
+
is_read_only: false, is_sortable: true))
|
|
30
|
+
add_field('group_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
31
|
+
is_read_only: false, is_sortable: true))
|
|
32
|
+
add_field('organization_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
33
|
+
is_read_only: false, is_sortable: true))
|
|
34
|
+
add_field('external_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
35
|
+
is_read_only: false, is_sortable: false))
|
|
36
|
+
add_field('requester_email', ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL],
|
|
37
|
+
is_read_only: true, is_sortable: false))
|
|
38
|
+
add_field('tags', ColumnSchema.new(column_type: 'Json', filter_operators: [],
|
|
39
|
+
is_read_only: false, is_sortable: false))
|
|
40
|
+
add_field('url', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
41
|
+
is_read_only: true, is_sortable: false))
|
|
42
|
+
add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
43
|
+
is_read_only: true, is_sortable: true))
|
|
44
|
+
add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
45
|
+
is_read_only: true, is_sortable: true))
|
|
46
|
+
|
|
47
|
+
@custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def define_relations
|
|
51
|
+
add_field('requester', ManyToOneSchema.new(
|
|
52
|
+
foreign_collection: 'ZendeskUser',
|
|
53
|
+
foreign_key: 'requester_id',
|
|
54
|
+
foreign_key_target: 'id'
|
|
55
|
+
))
|
|
56
|
+
add_field('assignee', ManyToOneSchema.new(
|
|
57
|
+
foreign_collection: 'ZendeskUser',
|
|
58
|
+
foreign_key: 'assignee_id',
|
|
59
|
+
foreign_key_target: 'id'
|
|
60
|
+
))
|
|
61
|
+
add_field('organization', ManyToOneSchema.new(
|
|
62
|
+
foreign_collection: 'ZendeskOrganization',
|
|
63
|
+
foreign_key: 'organization_id',
|
|
64
|
+
foreign_key_target: 'id'
|
|
65
|
+
))
|
|
66
|
+
add_field('comments', ColumnSchema.new(column_type: [Ticket::COMMENT_THREAD_SCHEMA],
|
|
67
|
+
filter_operators: [], is_read_only: true))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < BaseCollection
|
|
4
|
+
module Serializer
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def serialize(ticket, emails = {})
|
|
8
|
+
attrs = attrs_of(ticket)
|
|
9
|
+
result = base_attributes(attrs, emails)
|
|
10
|
+
cf_values = Array(attrs['custom_fields']).to_h { |f| [f['id'], f['value']] }
|
|
11
|
+
@custom_fields.each { |cf| result[cf[:column_name]] = cf_values[cf[:zendesk_id]] }
|
|
12
|
+
result
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def base_attributes(attrs, emails)
|
|
16
|
+
{
|
|
17
|
+
'id' => attrs['id'], 'subject' => attrs['subject'],
|
|
18
|
+
'description' => attrs['description'], 'status' => attrs['status'],
|
|
19
|
+
'priority' => attrs['priority'], 'ticket_type' => attrs['type'],
|
|
20
|
+
'requester_id' => attrs['requester_id'], 'assignee_id' => attrs['assignee_id'],
|
|
21
|
+
'group_id' => attrs['group_id'], 'organization_id' => attrs['organization_id'],
|
|
22
|
+
'external_id' => attrs['external_id'],
|
|
23
|
+
'requester_email' => emails[attrs['requester_id']],
|
|
24
|
+
'tags' => attrs['tags'], 'url' => attrs['url'],
|
|
25
|
+
'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at']
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class Ticket < BaseCollection
|
|
4
|
+
include SchemaDefinition
|
|
5
|
+
include RelationEmbedder
|
|
6
|
+
include CommentsEmbedder
|
|
7
|
+
include Serializer
|
|
8
|
+
|
|
9
|
+
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
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
|
+
ZENDESK_SORTABLE = {
|
|
16
|
+
'updated_at' => 'updated_at',
|
|
17
|
+
'created_at' => 'created_at',
|
|
18
|
+
'priority' => 'priority',
|
|
19
|
+
'status' => 'status',
|
|
20
|
+
'ticket_type' => 'ticket_type'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
COMMENT_THREAD_SCHEMA = {
|
|
24
|
+
'id' => 'Number',
|
|
25
|
+
'body' => 'String',
|
|
26
|
+
'html_body' => 'String',
|
|
27
|
+
'public' => 'Boolean',
|
|
28
|
+
'author_email' => 'String',
|
|
29
|
+
'author_name' => 'String',
|
|
30
|
+
'created_at' => 'Date'
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize(datasource, custom_fields: [])
|
|
34
|
+
super(datasource, 'ZendeskTicket')
|
|
35
|
+
@custom_fields = custom_fields
|
|
36
|
+
define_schema
|
|
37
|
+
define_relations
|
|
38
|
+
enable_search
|
|
39
|
+
enable_count
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list(caller, filter, projection)
|
|
43
|
+
records = fetch_records(caller, filter)
|
|
44
|
+
emails = needs_requester_email?(projection) ? bulk_fetch_emails(records) : {}
|
|
45
|
+
rows = records.map { |t| project(serialize(t, emails), projection) }
|
|
46
|
+
embed_relations(records, rows, projection)
|
|
47
|
+
embed_comments(records, rows) if want_comments?(projection)
|
|
48
|
+
rows
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create(_caller, data)
|
|
52
|
+
payload = build_payload(data, on_create: true)
|
|
53
|
+
created = datasource.client.create_ticket(payload)
|
|
54
|
+
serialize(created)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update(caller, filter, patch)
|
|
58
|
+
ids = ids_for(caller, filter)
|
|
59
|
+
payload = build_payload(patch, on_create: false)
|
|
60
|
+
ids.each { |id| datasource.client.update_ticket(id, payload) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def delete(caller, filter)
|
|
64
|
+
ids_for(caller, filter).each { |id| datasource.client.delete_ticket(id) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
protected
|
|
68
|
+
|
|
69
|
+
def aggregate_count(caller, filter)
|
|
70
|
+
datasource.client.count('ticket', query: build_zendesk_query(caller, filter))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# `description` only writes the initial comment at creation time; Zendesk
|
|
76
|
+
# has no update path for it, so it's dropped on patch.
|
|
77
|
+
def build_payload(data, on_create:)
|
|
78
|
+
attrs = data.transform_keys(&:to_s)
|
|
79
|
+
custom_fields, base = split_custom_fields(attrs)
|
|
80
|
+
%w[id requester_email url created_at updated_at].each { |k| base.delete(k) }
|
|
81
|
+
base['type'] = base.delete('ticket_type') if base.key?('ticket_type')
|
|
82
|
+
|
|
83
|
+
description = base.delete('description')
|
|
84
|
+
base['comment'] = { 'body' => description } if on_create && description && !description.empty?
|
|
85
|
+
|
|
86
|
+
base['custom_fields'] = custom_fields unless custom_fields.empty?
|
|
87
|
+
base
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def split_custom_fields(attrs)
|
|
91
|
+
cf_by_column = @custom_fields.to_h { |cf| [cf[:column_name], cf[:zendesk_id]] }
|
|
92
|
+
custom = []
|
|
93
|
+
rest = attrs.each_with_object({}) do |(k, v), h|
|
|
94
|
+
if (zendesk_id = cf_by_column[k])
|
|
95
|
+
custom << { 'id' => zendesk_id, 'value' => v }
|
|
96
|
+
else
|
|
97
|
+
h[k] = v
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
[custom, rest]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fetch_records(caller, filter)
|
|
104
|
+
ids = extract_id_lookup(filter.condition_tree)
|
|
105
|
+
if ids
|
|
106
|
+
by_id = datasource.client.fetch_tickets_by_ids(ids)
|
|
107
|
+
return ids.filter_map { |id| by_id[id] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE)
|
|
111
|
+
page, per_page = translate_page(filter.page)
|
|
112
|
+
|
|
113
|
+
datasource.client.search('ticket', query: build_zendesk_query(caller, filter),
|
|
114
|
+
sort_by: sort_by, sort_order: sort_order,
|
|
115
|
+
page: page, per_page: per_page)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def needs_requester_email?(projection)
|
|
119
|
+
projection.nil? || Array(projection).map(&:to_s).include?('requester_email')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def bulk_fetch_emails(records)
|
|
123
|
+
ids = records.map { |t| attrs_of(t)['requester_id'] }
|
|
124
|
+
datasource.client.fetch_user_emails(ids)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Collections
|
|
3
|
+
class User < BaseCollection
|
|
4
|
+
include Searchable
|
|
5
|
+
|
|
6
|
+
ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
|
|
7
|
+
OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
|
|
8
|
+
ENUM_ROLE = %w[end-user agent admin].freeze
|
|
9
|
+
|
|
10
|
+
ZENDESK_SORTABLE = {
|
|
11
|
+
'created_at' => 'created_at',
|
|
12
|
+
'updated_at' => 'updated_at',
|
|
13
|
+
'name' => 'name'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(datasource, custom_fields: [])
|
|
17
|
+
super(datasource, 'ZendeskUser')
|
|
18
|
+
@custom_fields = custom_fields
|
|
19
|
+
define_schema
|
|
20
|
+
define_relations
|
|
21
|
+
enable_search
|
|
22
|
+
enable_count
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create(_caller, data)
|
|
26
|
+
payload = build_payload(data)
|
|
27
|
+
created = datasource.client.create_user(payload)
|
|
28
|
+
serialize(created)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def update(caller, filter, patch)
|
|
32
|
+
ids = ids_for(caller, filter)
|
|
33
|
+
payload = build_payload(patch)
|
|
34
|
+
ids.each { |id| datasource.client.update_user(id, payload) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(caller, filter)
|
|
38
|
+
ids_for(caller, filter).each { |id| datasource.client.delete_user(id) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
def zendesk_resource = 'user'
|
|
44
|
+
def sortable_fields = ZENDESK_SORTABLE
|
|
45
|
+
def find_one(id) = datasource.client.find_user(id)
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_payload(data)
|
|
50
|
+
attrs = data.transform_keys(&:to_s)
|
|
51
|
+
cf_keys = @custom_fields.to_h { |cf| [cf[:column_name], cf[:zendesk_key]] }
|
|
52
|
+
user_fields = {}
|
|
53
|
+
base = attrs.each_with_object({}) do |(k, v), h|
|
|
54
|
+
if (key = cf_keys[k])
|
|
55
|
+
user_fields[key] = v
|
|
56
|
+
else
|
|
57
|
+
h[k] = v
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
%w[id created_at updated_at].each { |k| base.delete(k) }
|
|
61
|
+
base['user_fields'] = user_fields unless user_fields.empty?
|
|
62
|
+
base
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def define_schema
|
|
66
|
+
add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
67
|
+
is_primary_key: true, is_read_only: true, is_sortable: true))
|
|
68
|
+
add_field('email', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
69
|
+
is_read_only: false, is_sortable: false))
|
|
70
|
+
add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
71
|
+
is_read_only: false, is_sortable: true))
|
|
72
|
+
add_field('role', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS,
|
|
73
|
+
enum_values: ENUM_ROLE, is_read_only: false, is_sortable: false))
|
|
74
|
+
add_field('phone', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS,
|
|
75
|
+
is_read_only: false, is_sortable: false))
|
|
76
|
+
add_field('organization_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS,
|
|
77
|
+
is_read_only: false, is_sortable: false))
|
|
78
|
+
add_field('time_zone', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
79
|
+
is_read_only: false, is_sortable: false))
|
|
80
|
+
add_field('locale', ColumnSchema.new(column_type: 'String', filter_operators: [],
|
|
81
|
+
is_read_only: false, is_sortable: false))
|
|
82
|
+
add_field('verified', ColumnSchema.new(column_type: 'Boolean', filter_operators: STRING_OPS,
|
|
83
|
+
is_read_only: false, is_sortable: false))
|
|
84
|
+
add_field('suspended', ColumnSchema.new(column_type: 'Boolean', filter_operators: STRING_OPS,
|
|
85
|
+
is_read_only: false, is_sortable: false))
|
|
86
|
+
add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
87
|
+
is_read_only: true, is_sortable: true))
|
|
88
|
+
add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS,
|
|
89
|
+
is_read_only: true, is_sortable: true))
|
|
90
|
+
|
|
91
|
+
@custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def define_relations
|
|
95
|
+
add_field('organization', ManyToOneSchema.new(foreign_collection: 'ZendeskOrganization',
|
|
96
|
+
foreign_key: 'organization_id', foreign_key_target: 'id'))
|
|
97
|
+
add_field('requested_tickets', OneToManySchema.new(foreign_collection: 'ZendeskTicket',
|
|
98
|
+
origin_key: 'requester_id', origin_key_target: 'id'))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def serialize(user)
|
|
102
|
+
attrs = attrs_of(user)
|
|
103
|
+
result = base_attributes(attrs)
|
|
104
|
+
user_fields = attrs['user_fields'] || {}
|
|
105
|
+
@custom_fields.each { |cf| result[cf[:column_name]] = user_fields[cf[:zendesk_key]] }
|
|
106
|
+
result
|
|
107
|
+
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
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_reader :subdomain, :username, :token
|
|
4
|
+
|
|
5
|
+
def initialize(subdomain:, username:, token:)
|
|
6
|
+
@subdomain = subdomain
|
|
7
|
+
@username = username
|
|
8
|
+
@token = token
|
|
9
|
+
validate!
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def url
|
|
13
|
+
"https://#{@subdomain}.zendesk.com/api/v2"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def validate!
|
|
19
|
+
missing = []
|
|
20
|
+
missing << 'subdomain' if blank?(@subdomain)
|
|
21
|
+
missing << 'username' if blank?(@username)
|
|
22
|
+
missing << 'token' if blank?(@token)
|
|
23
|
+
return if missing.empty?
|
|
24
|
+
|
|
25
|
+
raise ConfigurationError,
|
|
26
|
+
"ForestAdminDatasourceZendesk missing required config: #{missing.join(", ")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def blank?(value)
|
|
30
|
+
value.nil? || value.to_s.strip.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
class Datasource < ForestAdminDatasourceToolkit::Datasource
|
|
3
|
+
attr_reader :client, :configuration, :custom_field_mapping
|
|
4
|
+
|
|
5
|
+
def initialize(subdomain:, username:, token:)
|
|
6
|
+
super()
|
|
7
|
+
@configuration = Configuration.new(subdomain: subdomain, username: username, token: token)
|
|
8
|
+
@client = Client.new(@configuration)
|
|
9
|
+
@custom_field_mapping = {}
|
|
10
|
+
|
|
11
|
+
register_collections
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def register_collections
|
|
17
|
+
introspector = Schema::CustomFieldsIntrospector.new(@client)
|
|
18
|
+
|
|
19
|
+
ticket_cf = introspector.ticket_custom_fields
|
|
20
|
+
user_cf = introspector.user_custom_fields
|
|
21
|
+
org_cf = introspector.organization_custom_fields
|
|
22
|
+
|
|
23
|
+
add_collection(Collections::Ticket.new(self, custom_fields: ticket_cf))
|
|
24
|
+
add_collection(Collections::User.new(self, custom_fields: user_cf))
|
|
25
|
+
add_collection(Collections::Organization.new(self, custom_fields: org_cf))
|
|
26
|
+
|
|
27
|
+
@custom_field_mapping = build_custom_field_mapping(ticket_cf, user_cf, org_cf)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Forest column name -> Zendesk Search field name. Lives on the instance
|
|
31
|
+
# (not the translator class) so multiple Zendesk datasources in the same
|
|
32
|
+
# agent don't share state.
|
|
33
|
+
def build_custom_field_mapping(ticket_cf, user_cf, org_cf)
|
|
34
|
+
mapping = {}
|
|
35
|
+
ticket_cf.each { |cf| mapping[cf[:column_name]] = "custom_field_#{cf[:zendesk_id]}" }
|
|
36
|
+
(user_cf + org_cf).each do |cf|
|
|
37
|
+
next unless cf[:zendesk_key]
|
|
38
|
+
|
|
39
|
+
mapping[cf[:column_name]] ||= cf[:zendesk_key]
|
|
40
|
+
end
|
|
41
|
+
mapping
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
require 'active_support/core_ext/time/zones'
|
|
2
|
+
|
|
3
|
+
module ForestAdminDatasourceZendesk
|
|
4
|
+
module Query
|
|
5
|
+
# See https://developer.zendesk.com/api-reference/ticketing/ticket-management/search/
|
|
6
|
+
#
|
|
7
|
+
# Unsupported operators raise UnsupportedOperatorError rather than
|
|
8
|
+
# silently producing the wrong query. Only the AND aggregator is
|
|
9
|
+
# supported (Zendesk Search has no general OR).
|
|
10
|
+
class ConditionTreeTranslator
|
|
11
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
12
|
+
Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch
|
|
13
|
+
Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf
|
|
14
|
+
|
|
15
|
+
def self.call(condition_tree, timezone: nil, custom_fields: {})
|
|
16
|
+
return '' if condition_tree.nil?
|
|
17
|
+
|
|
18
|
+
new(timezone: timezone, custom_fields: custom_fields).translate(condition_tree)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(timezone: nil, custom_fields: {})
|
|
22
|
+
@timezone = timezone || 'UTC'
|
|
23
|
+
@custom_fields = custom_fields || {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def translate(node)
|
|
27
|
+
case node
|
|
28
|
+
when Branch then translate_branch(node)
|
|
29
|
+
when Leaf then translate_leaf(node)
|
|
30
|
+
else
|
|
31
|
+
raise UnsupportedOperatorError, "Unknown condition node: #{node.class}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def translate_branch(branch)
|
|
38
|
+
unless branch.aggregator.to_s.casecmp('and').zero?
|
|
39
|
+
raise UnsupportedOperatorError,
|
|
40
|
+
"Zendesk Search API does not support arbitrary OR aggregation; got #{branch.aggregator.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
branch.conditions.map { |c| translate(c) }.reject(&:empty?).join(' ')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def translate_leaf(leaf)
|
|
47
|
+
field = mapped_field(leaf.field)
|
|
48
|
+
value = leaf.value
|
|
49
|
+
|
|
50
|
+
if leaf.field == 'requester_email' && leaf.operator == Operators::EQUAL
|
|
51
|
+
return "requester:#{format_value(value)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
case leaf.operator
|
|
55
|
+
when Operators::EQUAL then "#{field}:#{format_value(value)}"
|
|
56
|
+
when Operators::NOT_EQUAL then "-#{field}:#{format_value(value)}"
|
|
57
|
+
when Operators::IN then translate_in(field, value, negate: false)
|
|
58
|
+
when Operators::NOT_IN then translate_in(field, value, negate: true)
|
|
59
|
+
when Operators::GREATER_THAN, Operators::AFTER then "#{field}>#{format_value(value)}"
|
|
60
|
+
when Operators::LESS_THAN, Operators::BEFORE then "#{field}<#{format_value(value)}"
|
|
61
|
+
when Operators::PRESENT then "#{field}:*"
|
|
62
|
+
when Operators::BLANK then "-#{field}:*"
|
|
63
|
+
else
|
|
64
|
+
raise UnsupportedOperatorError,
|
|
65
|
+
"Zendesk datasource does not yet translate operator '#{leaf.operator}' on field '#{field}'"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# An empty `IN []` would translate to '', which the branch then drops —
|
|
70
|
+
# silently turning "match nothing" into "match everything". Raise instead.
|
|
71
|
+
def translate_in(field, value, negate:)
|
|
72
|
+
values = Array(value)
|
|
73
|
+
if values.empty?
|
|
74
|
+
raise UnsupportedOperatorError,
|
|
75
|
+
"#{negate ? "NOT_IN" : "IN"} on field '#{field}' was given an empty array; " \
|
|
76
|
+
'pass at least one value or use the BLANK / PRESENT operators.'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
prefix = negate ? '-' : ''
|
|
80
|
+
values.map { |v| "#{prefix}#{field}:#{format_value(v)}" }.join(' ')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def mapped_field(field)
|
|
84
|
+
@custom_fields[field] || field
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_value(value)
|
|
88
|
+
case value
|
|
89
|
+
when nil then raise_nil_value_error
|
|
90
|
+
when Time, DateTime then value.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
91
|
+
when Date then format_date(value)
|
|
92
|
+
when String then format_string(value)
|
|
93
|
+
else value.to_s
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# `field:` with a nil value would parse as a presence check on Zendesk's
|
|
98
|
+
# side — silently the wrong query. PRESENT / BLANK is the supported path.
|
|
99
|
+
def raise_nil_value_error
|
|
100
|
+
raise UnsupportedOperatorError,
|
|
101
|
+
'Filter value is nil; use the PRESENT or BLANK operator to filter for absence.'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_date(value)
|
|
105
|
+
Time.use_zone(@timezone) do
|
|
106
|
+
Time.zone.local(value.year, value.month, value.day).utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
107
|
+
end
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
ForestAdminDatasourceZendesk.logger.warn(
|
|
110
|
+
"[forest_admin_datasource_zendesk] unknown timezone '#{@timezone}', falling back to UTC"
|
|
111
|
+
)
|
|
112
|
+
value.strftime('%Y-%m-%dT00:00:00Z')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def format_string(value)
|
|
116
|
+
return value unless value.match?(/[\s"():-]/)
|
|
117
|
+
|
|
118
|
+
%("#{value.gsub('"', '\\"')}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module ForestAdminDatasourceZendesk
|
|
2
|
+
module Schema
|
|
3
|
+
# Returns entries shaped { column_name:, zendesk_id:, zendesk_key:, schema: }.
|
|
4
|
+
# `zendesk_key` is set for user/org fields (Zendesk addresses those by key);
|
|
5
|
+
# ticket fields use `zendesk_id` only.
|
|
6
|
+
class CustomFieldsIntrospector
|
|
7
|
+
ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema
|
|
8
|
+
Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators
|
|
9
|
+
|
|
10
|
+
STRING_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN,
|
|
11
|
+
Operators::PRESENT, Operators::BLANK].freeze
|
|
12
|
+
NUMBER_OPS = (STRING_OPS + [Operators::GREATER_THAN, Operators::LESS_THAN]).freeze
|
|
13
|
+
DATE_OPS = [Operators::EQUAL, Operators::BEFORE, Operators::AFTER,
|
|
14
|
+
Operators::PRESENT, Operators::BLANK].freeze
|
|
15
|
+
|
|
16
|
+
ZENDESK_TO_COLUMN_TYPE = {
|
|
17
|
+
'text' => 'String',
|
|
18
|
+
'textarea' => 'String',
|
|
19
|
+
'regexp' => 'String',
|
|
20
|
+
'partialcreditcard' => 'String',
|
|
21
|
+
'integer' => 'Number',
|
|
22
|
+
'decimal' => 'Number',
|
|
23
|
+
'date' => 'Dateonly',
|
|
24
|
+
'checkbox' => 'Boolean',
|
|
25
|
+
'dropdown' => 'Enum',
|
|
26
|
+
'tagger' => 'Enum',
|
|
27
|
+
'multiselect' => 'Json',
|
|
28
|
+
'lookup' => 'Number'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(client)
|
|
32
|
+
@client = client
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ticket_custom_fields
|
|
36
|
+
introspect(@client.fetch_ticket_fields, key_strategy: :ticket)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def user_custom_fields
|
|
40
|
+
introspect(@client.fetch_user_fields, key_strategy: :user_or_org)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def organization_custom_fields
|
|
44
|
+
introspect(@client.fetch_organization_fields, key_strategy: :user_or_org)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def introspect(raw_fields, key_strategy:)
|
|
50
|
+
Array(raw_fields)
|
|
51
|
+
.select { |raw| usable_field?(raw, key_strategy) }
|
|
52
|
+
.filter_map { |raw| build_entry(raw, key_strategy) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Skip non-removable ticket fields: those are system fields the Ticket
|
|
56
|
+
# schema already declares (subject, status, ...), and re-adding them
|
|
57
|
+
# would conflict.
|
|
58
|
+
def usable_field?(raw, key_strategy)
|
|
59
|
+
return false unless raw['active']
|
|
60
|
+
return false if key_strategy == :ticket && raw['removable'] == false
|
|
61
|
+
|
|
62
|
+
ZENDESK_TO_COLUMN_TYPE.key?(raw['type'])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_entry(raw, key_strategy)
|
|
66
|
+
column_type = ZENDESK_TO_COLUMN_TYPE.fetch(raw['type'])
|
|
67
|
+
name, key = column_naming(raw, key_strategy)
|
|
68
|
+
{ column_name: name, zendesk_id: raw['id'], zendesk_key: key,
|
|
69
|
+
schema: build_schema(raw, column_type) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def column_naming(raw, strategy)
|
|
73
|
+
case strategy
|
|
74
|
+
when :ticket then ["custom_#{raw["id"]}", nil]
|
|
75
|
+
when :user_or_org
|
|
76
|
+
key = raw['key'] || "custom_#{raw["id"]}"
|
|
77
|
+
[key, key]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_schema(raw, column_type)
|
|
82
|
+
opts = {
|
|
83
|
+
column_type: column_type,
|
|
84
|
+
filter_operators: filter_operators_for(column_type),
|
|
85
|
+
is_read_only: false,
|
|
86
|
+
is_sortable: false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if column_type == 'Enum'
|
|
90
|
+
opts[:enum_values] = Array(raw['custom_field_options']).filter_map { |o| o['value'] }
|
|
91
|
+
# Forest rejects empty Enum schemas; fall back to String so the column
|
|
92
|
+
# still appears.
|
|
93
|
+
if opts[:enum_values].empty?
|
|
94
|
+
opts[:column_type] = 'String'
|
|
95
|
+
opts[:filter_operators] = STRING_OPS
|
|
96
|
+
opts.delete(:enum_values)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ColumnSchema.new(**opts)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def filter_operators_for(column_type)
|
|
104
|
+
case column_type
|
|
105
|
+
when 'Number' then NUMBER_OPS
|
|
106
|
+
when 'Dateonly' then DATE_OPS
|
|
107
|
+
when 'Boolean' then [Operators::EQUAL, Operators::NOT_EQUAL]
|
|
108
|
+
when 'Json' then []
|
|
109
|
+
else STRING_OPS
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|