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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -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