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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module ForestAdminDatasourceZendesk
2
+ VERSION = "1.28.1"
3
+ end