diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b2047dfb..fdb2438cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,7 @@ jobs: - forest_admin_datasource_mongoid - forest_admin_rpc_agent - forest_admin_datasource_rpc + - forest_admin_datasource_zendesk steps: - name: Checkout @@ -66,6 +67,7 @@ jobs: - forest_admin_datasource_mongoid - forest_admin_rpc_agent - forest_admin_datasource_rpc + - forest_admin_datasource_zendesk services: mongodb: image: mongo:latest @@ -129,7 +131,7 @@ jobs: with: verbose: true oidc: true - files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json + files: ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_rpc_agent/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_rpc/coverage.json,${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_zendesk/coverage.json deploy: name: Release package diff --git a/.releaserc.js b/.releaserc.js index 234ee0308..e66975f25 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -27,7 +27,8 @@ module.exports = { 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_rails/lib/forest_admin_rails/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb; '+ 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/version.rb; '+ - 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; ', + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb; '+ + 'sed -i \'s/VERSION = ".*"/VERSION = "${nextRelease.version}"/g\' packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb; ', successCmd: '( cd packages/forest_admin_agent && gem build && gem push forest_admin_agent-*.gem );' + '( cd packages/forest_admin_datasource_active_record && gem build && gem push forest_admin_datasource_active_record-*.gem );' + @@ -37,7 +38,8 @@ module.exports = { '( cd packages/forest_admin_rails && gem build && gem push forest_admin_rails-*.gem );' + '( cd packages/forest_admin_datasource_mongoid && gem build && gem push forest_admin_datasource_mongoid-*.gem );' + '( cd packages/forest_admin_rpc_agent && gem build && gem push forest_admin_rpc_agent-*.gem );' + - '( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' , + '( cd packages/forest_admin_datasource_rpc && gem build && gem push forest_admin_datasource_rpc-*.gem );' + + '( cd packages/forest_admin_datasource_zendesk && gem build && gem push forest_admin_datasource_zendesk-*.gem );' , }, ], [ @@ -56,6 +58,7 @@ module.exports = { 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb', 'packages/forest_admin_rpc_agent/lib/forest_admin_rpc_agent/version.rb', 'packages/forest_admin_datasource_rpc/lib/forest_admin_datasource_rpc/version.rb', + 'packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb', 'package.json' ], }, diff --git a/packages/forest_admin_datasource_zendesk/.gitignore b/packages/forest_admin_datasource_zendesk/.gitignore new file mode 100644 index 000000000..40b2bd6b9 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/.gitignore @@ -0,0 +1,7 @@ +*.gem +.bundle/ +Gemfile.lock +coverage/ +pkg/ +tmp/ +.rspec_status diff --git a/packages/forest_admin_datasource_zendesk/.rspec b/packages/forest_admin_datasource_zendesk/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/packages/forest_admin_datasource_zendesk/.rubocop.yml b/packages/forest_admin_datasource_zendesk/.rubocop.yml new file mode 100644 index 000000000..8a2c6b38e --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/.rubocop.yml @@ -0,0 +1,42 @@ +inherit_from: ../../.rubocop.yml + +# Per-package overrides. The spec suite makes heavy use of RSpec patterns that +# rubocop's defaults flag stylistically but that don't affect correctness: +# - constant aliases at the top of describe blocks for readability (Leaf, Filter, ...) +# - combined stub + call expectation via `expect(x).to receive(...).and_return(...)` +# - "must not be called" assertions via `expect(x).not_to receive(...)` +# Disabling these for spec/** keeps the tests concise without weakening lint +# coverage of the production code. + +# Schemas in this package are wide-fact-tables (one ColumnSchema per real +# Zendesk field) and are intrinsically long. Bumping the limits is cheaper +# than splitting them into noise. +Metrics/ClassLength: + Max: 200 + +Metrics/MethodLength: + Max: 25 + +Metrics/ParameterLists: + Max: 6 + +Layout/LineLength: + Max: 130 + Exclude: + - 'spec/**/*' + +RSpec/StubbedMock: + Exclude: + - 'spec/**/*' + +RSpec/MessageSpies: + Exclude: + - 'spec/**/*' + +RSpec/LeakyConstantDeclaration: + Exclude: + - 'spec/**/*' + +Lint/ConstantDefinitionInBlock: + Exclude: + - 'spec/**/*' diff --git a/packages/forest_admin_datasource_zendesk/Gemfile b/packages/forest_admin_datasource_zendesk/Gemfile new file mode 100644 index 000000000..5f975babf --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +gemspec + +gem 'forest_admin_datasource_toolkit' +gem 'rake', '~> 13.0' +gem 'rubocop', '~> 1.21' + +group :development, :test do + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_zendesk/Gemfile-test b/packages/forest_admin_datasource_zendesk/Gemfile-test new file mode 100644 index 000000000..69df26870 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/Gemfile-test @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in forest_admin_datasource_zendesk.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'rubocop', '~> 1.21' + +group :development, :test do + gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'rspec', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' + gem 'webmock', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_zendesk/Rakefile b/packages/forest_admin_datasource_zendesk/Rakefile new file mode 100644 index 000000000..4c774a2bf --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/Rakefile @@ -0,0 +1,6 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec b/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec new file mode 100644 index 000000000..ad793ba64 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec @@ -0,0 +1,35 @@ +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) + +require_relative 'lib/forest_admin_datasource_zendesk/version' + +Gem::Specification.new do |spec| + spec.name = 'forest_admin_datasource_zendesk' + spec.version = ForestAdminDatasourceZendesk::VERSION + spec.authors = ['Forest Admin'] + spec.email = ['contact@forestadmin.com'] + spec.homepage = 'https://www.forestadmin.com' + spec.summary = 'Zendesk datasource for Forest Admin Ruby agent.' + spec.description = 'Surface Zendesk tickets, users, organizations and comments as Forest Admin collections.' + spec.license = 'GPL-3.0' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby' + spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'true' + + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + spec.add_dependency 'activesupport', '>= 6.1' + spec.add_dependency 'zeitwerk', '~> 2.3' + spec.add_dependency 'zendesk_api', '~> 3.0' +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk.rb new file mode 100644 index 000000000..88a1c1338 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk.rb @@ -0,0 +1,35 @@ +require_relative 'forest_admin_datasource_zendesk/version' +require 'logger' +require 'zeitwerk' +require 'forest_admin_datasource_toolkit' +require 'zendesk_api' + +loader = Zeitwerk::Loader.for_gem +loader.setup + +module ForestAdminDatasourceZendesk + class Error < StandardError; end + class ConfigurationError < Error; end + class UnsupportedOperatorError < Error; end + + # Raised when a Zendesk API call fails for any reason other than the + # well-known `RecordNotFound`. Wraps the underlying error so callers can + # rescue a single class without depending on the zendesk_api gem. + class APIError < Error; end + + class << self + attr_writer :logger + + def logger + @logger ||= default_logger + end + + private + + def default_logger + return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger + + Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_zendesk' } + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/client.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/client.rb new file mode 100644 index 000000000..529b1a64e --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/client.rb @@ -0,0 +1,164 @@ +module ForestAdminDatasourceZendesk + # HTTP-level wrapper around the zendesk_api gem. + # + # Error policy: + # - "Critical" methods (count, fetch_ticket_comments) raise APIError on any + # failure other than 404. Returning a safe default would silently corrupt + # the data the user is looking at — better to surface a 500 than to mislead. + # - "Best-effort" methods (bulk user/org lookups, schema introspection) + # log a warning and degrade to a safe default. These are enrichment paths; + # missing data shows up as nil/empty in the UI rather than crashing the + # whole page render. + class Client + MAX_PER_PAGE = 100 + + def initialize(configuration) + @configuration = configuration + end + + # ---------- Search (critical) ---------- + + # `opts` accepts: :query (required), :sort_by, :sort_order, :page, + # :per_page. We use an options hash rather than five named args so the + # call sites (the Searchable mixin) stay tidy and the signature stays + # narrow. + def search(type, **opts) + params = build_search_params(type, opts) + must_succeed("search(#{type})") { api.search(params).to_a } + end + + def count(type, query:) + must_succeed("count(#{type})") do + body = api.connection.get('search/count', query: compose_query(type, query)).body + Integer(body['count'] || 0) + end + end + + def fetch_ticket_comments(ticket_id) + must_succeed("fetch_ticket_comments(#{ticket_id})") do + Array(api.connection.get("tickets/#{ticket_id}/comments").body['comments']) + end + end + + # ---------- Direct fetches (404 -> nil; other errors propagate) ---------- + + def find_ticket(id) + api.tickets.find(id: id) + rescue ZendeskAPI::Error::RecordNotFound + nil + end + + def find_user(id) + api.users.find(id: id) + rescue ZendeskAPI::Error::RecordNotFound + nil + end + + def find_organization(id) + api.organizations.find(id: id) + rescue ZendeskAPI::Error::RecordNotFound + nil + end + + # ---------- Bulk lookups (best-effort) ---------- + + def fetch_user_emails(ids) + best_effort('fetch_user_emails', default: {}) do + bulk_show_many('users', ids) { |u| [u['id'], u['email']] } + end + end + + def fetch_users_by_ids(ids) + best_effort('fetch_users_by_ids', default: {}) do + bulk_show_many('users', ids) { |u| [u['id'], u] } + end + end + + def fetch_organizations_by_ids(ids) + best_effort('fetch_organizations_by_ids', default: {}) do + bulk_show_many('organizations', ids) { |o| [o['id'], o] } + end + end + + # ---------- Schema introspection (best-effort; runs at boot) ---------- + + def fetch_ticket_fields + best_effort('fetch_ticket_fields (custom fields will be unavailable)', default: []) do + Array(api.connection.get('ticket_fields').body['ticket_fields']) + end + end + + def fetch_user_fields + best_effort('fetch_user_fields (custom fields will be unavailable)', default: []) do + Array(api.connection.get('user_fields').body['user_fields']) + end + end + + def fetch_organization_fields + best_effort('fetch_organization_fields (custom fields will be unavailable)', default: []) do + Array(api.connection.get('organization_fields').body['organization_fields']) + end + end + + def raw + api + end + + private + + def bulk_show_many(resource, ids) + ids = Array(ids).compact.uniq + return {} if ids.empty? + + ids.each_slice(MAX_PER_PAGE).with_object({}) do |batch, acc| + body = api.connection.get("#{resource}/show_many", ids: batch.join(',')).body + Array(body[resource]).each do |item| + k, v = yield(item) + acc[k] = v + end + end + end + + def must_succeed(operation) + yield + rescue StandardError => e + # find_* methods rescue RecordNotFound themselves and return nil; if a + # 404 reaches us here (for search/count), surface it like any other + # failure rather than silently mapping to nil/zero. + raise APIError, "Zendesk API call failed: #{operation}: #{e.class}: #{e.message}" + end + + def best_effort(operation, default:) + yield + rescue StandardError => e + ForestAdminDatasourceZendesk.logger.warn( + "[forest_admin_datasource_zendesk] #{operation} failed; degrading: #{e.class}: #{e.message}" + ) + default + end + + def compose_query(type, query) + [type ? "type:#{type}" : nil, query.to_s.strip].compact.reject(&:empty?).join(' ') + end + + def build_search_params(type, opts) + params = { + query: compose_query(type, opts[:query]), + per_page: [opts[:per_page] || MAX_PER_PAGE, MAX_PER_PAGE].min, + page: opts[:page] || 1 + } + params[:sort_by] = opts[:sort_by] if opts[:sort_by] + params[:sort_order] = opts[:sort_order] if opts[:sort_order] + params + end + + def api + @api ||= ZendeskAPI::Client.new do |c| + c.url = @configuration.url + c.username = @configuration.username + c.token = @configuration.token + c.retry = true + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb new file mode 100644 index 000000000..fc9e176d9 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb @@ -0,0 +1,109 @@ +module ForestAdminDatasourceZendesk + module Collections + class BaseCollection < ForestAdminDatasourceToolkit::Collection + ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + STRING_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, + Operators::PRESENT, Operators::BLANK].freeze + NUMBER_OPS = (STRING_OPS + [Operators::GREATER_THAN, Operators::LESS_THAN]).freeze + DATE_OPS = [Operators::EQUAL, Operators::BEFORE, Operators::AFTER, + Operators::PRESENT, Operators::BLANK].freeze + + # Toolkit contract — subclasses override `aggregate_count` instead of + # touching the 4-arg signature directly. + def aggregate(caller, filter, aggregation, _limit = nil) + unless aggregation.operation == 'Count' && aggregation.field.nil? && aggregation.groups.empty? + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'Zendesk datasource only supports Count aggregation without groups.' + end + + [{ 'value' => aggregate_count(caller, filter), 'group' => {} }] + end + + protected + + # Default no-op; the Searchable mixin (and Ticket) override. + def aggregate_count(_caller, _filter) + raise NotImplementedError, "#{self.class} did not implement aggregate_count" + end + + # Pulls the value(s) from a leaf shaped like `id = N` or `id IN [...]`. + # Used by collections to short-circuit PK lookups (Zendesk Search has no + # `id:` operator, so /resource/{id} is the only viable path for show). + def extract_id_lookup(node) + return nil unless node.is_a?(Leaf) && node.field == 'id' + + case node.operator + when Operators::EQUAL then [node.value] + when Operators::IN then Array(node.value) + end + end + + # Filters projection down to direct columns (drops "relation:subfield" + # entries). Returns the record unchanged when projection is nil or + # contains only relation paths. + def project(record, projection) + return record if projection.nil? + + wanted = Array(projection).map(&:to_s).reject { |p| p.include?(':') } + return record if wanted.empty? + + wanted.each_with_object({}) { |k, h| h[k] = record[k] } + end + + # Translates a Forest Sort into Zendesk's [sort_by, sort_order] tuple, + # using the subclass-supplied allow-list. Unknown fields silently + # disable sorting (Zendesk's Search API only honours specific fields). + def translate_sort(sort, allow_list) + return [nil, nil] if sort.nil? || sort.empty? + + field, ascending = sort_field_and_direction(sort.first) + zd_field = allow_list[field.to_s] + return [nil, nil] unless zd_field + + [zd_field, ascending ? 'asc' : 'desc'] + end + + # Translates a Forest Page (offset/limit) into Zendesk's [page, per_page]. + def translate_page(page) + return [1, Client::MAX_PER_PAGE] if page.nil? + + per_page = page.limit&.positive? ? [page.limit, Client::MAX_PER_PAGE].min : Client::MAX_PER_PAGE + page_num = (page.offset.to_i / per_page) + 1 + [page_num, per_page] + end + + def attrs_of(record) + record.respond_to?(:attributes) ? record.attributes : record.to_h + end + + def timezone_for(caller) + return 'UTC' unless caller.respond_to?(:timezone) + + tz = caller.timezone + tz.nil? || tz.empty? ? 'UTC' : tz + end + + private + + # Sort entries arrive either as Sort::Clause objects (responding to + # `field`/`ascending`) or as plain hashes (the toolkit normalises them + # at construction time, but specs and a few code paths still build them + # by hand). Handle both. + # + # `key?` (rather than `||`) for the boolean: `entry[:ascending] || ...` + # would silently flip a descending sort to ascending if both symbol and + # string keys exist with different values, and falls through to the + # other key whenever ascending is explicitly false. + def sort_field_and_direction(entry) + return [entry.field, entry.ascending] if entry.respond_to?(:field) + + field = entry.key?(:field) ? entry[:field] : entry['field'] + ascending = entry.key?(:ascending) ? entry[:ascending] : entry['ascending'] + [field, ascending] + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/comment.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/comment.rb new file mode 100644 index 000000000..43ba09c8d --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/comment.rb @@ -0,0 +1,156 @@ +module ForestAdminDatasourceZendesk + module Collections + # Comments are *always* fetched in the context of a parent ticket + # (Zendesk: GET /tickets/{id}/comments). The collection's `list` only + # responds to filters that resolve to one or more `ticket_id` values + # (either directly via `ticket_id = N` / `ticket_id IN [...]`, or + # indirectly via the synthetic primary key `-`). + # Any other filter shape returns []. + class Comment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + + def initialize(datasource) + super(datasource, 'ZendeskComment') + define_schema + define_relations + end + + def list(_caller, filter, projection) + ticket_ids, comment_ids = resolve_scope(filter) + if ticket_ids.empty? + ForestAdminDatasourceZendesk.logger.info( + '[forest_admin_datasource_zendesk] ZendeskComment.list called without a ticket scope; ' \ + 'returning [] (use the Ticket -> comments relation to fetch comments)' + ) + return [] + end + + records = fetch_comments(ticket_ids, comment_ids) + records.map { |c| project(serialize(c), projection) } + end + + private + + # Resolves the filter into [ticket_ids, comment_ids]. Both come from a + # mix of direct `ticket_id` filters and synthetic-id filters + # (`id = "-"`). `comment_ids` may be nil + # (meaning "no narrowing — return all comments for the ticket"). + def resolve_scope(filter) + decoded = decoded_synthetic_pairs(filter) + ticket_ids = ((extract_field_lookup(filter.condition_tree, 'ticket_id') || []) + + decoded.map(&:last)).compact.uniq + comment_ids = decoded.filter_map(&:first) + [ticket_ids, comment_ids.empty? ? nil : comment_ids.uniq] + end + + # Only return pairs where BOTH halves are valid integers. A malformed + # synthetic id like `"abc-456"` would otherwise contribute its + # ticket_id to the scope and silently fetch every comment for that + # ticket — wrong, since the row the user clicked doesn't exist. + def decoded_synthetic_pairs(filter) + Array(extract_field_lookup(filter.condition_tree, 'id')) + .map { |sid| decode_synthetic_id(sid) } + .reject { |c, t| c.nil? || t.nil? } + end + + def fetch_comments(ticket_ids, comment_ids) + ticket_ids.flat_map do |ticket_id| + comments = datasource.client.fetch_ticket_comments(ticket_id).map do |c| + c.merge('ticket_id' => ticket_id) + end + comment_ids ? comments.select { |c| comment_ids.include?(c['id']) } : comments + end + end + + # Walks the (possibly-Branch) condition tree and collects equality/IN + # values for `field`. Returns nil if no matching leaf was found. + def extract_field_lookup(node, field) + leaves = collect_leaves(node).select { |l| l.field == field } + values = leaves.flat_map { |l| values_from_leaf(l) } + values.empty? ? nil : values + end + + def values_from_leaf(leaf) + case leaf.operator + when Operators::EQUAL then [leaf.value] + when Operators::IN then Array(leaf.value) + else [] + end + end + + def decode_synthetic_id(value) + # Format: "-". Both are positive integers. + parts = value.to_s.split('-') + return [nil, nil] unless parts.size == 2 + + parts.map { |p| Integer(p, 10, exception: false) } + end + + def collect_leaves(node) + case node + when Leaf then [node] + when Branch then node.conditions.flat_map { |c| collect_leaves(c) } + else [] + end + end + + def define_schema + # Synthetic composite primary key: a comment is only addressable in + # the context of its parent ticket (Zendesk has no /comments/{id} + # endpoint). We encode - as a single String + # PK because forest_admin_rails 1.26.2's URL constraint rejects '|' + # (used by the toolkit's native pack_id for composite keys). + add_field('id', ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL, Operators::IN], + is_primary_key: true, is_read_only: true, is_sortable: false)) + add_field('ticket_id', ColumnSchema.new(column_type: 'Number', filter_operators: [Operators::EQUAL, Operators::IN], + is_read_only: true, is_sortable: false)) + add_field('author_id', ColumnSchema.new(column_type: 'Number', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('body', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('html_body', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('plain_body', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('public', ColumnSchema.new(column_type: 'Boolean', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('via_channel', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: [], + is_read_only: true, is_sortable: false)) + end + + def define_relations + add_field('author', ManyToOneSchema.new( + foreign_collection: 'ZendeskUser', + foreign_key: 'author_id', + foreign_key_target: 'id' + )) + add_field('ticket', ManyToOneSchema.new( + foreign_collection: 'ZendeskTicket', + foreign_key: 'ticket_id', + foreign_key_target: 'id' + )) + end + + def serialize(comment) + attrs = attrs_of(comment) + { + 'id' => "#{attrs["id"]}-#{attrs["ticket_id"]}", + 'ticket_id' => attrs['ticket_id'], + 'author_id' => attrs['author_id'], + 'body' => attrs['body'], + 'html_body' => attrs['html_body'], + 'plain_body' => attrs['plain_body'] || attrs['body'], + 'public' => attrs['public'], + 'type' => attrs['type'], + 'via_channel' => attrs.dig('via', 'channel') || attrs.dig(:via, :channel), + 'created_at' => attrs['created_at'] + } + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/organization.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/organization.rb new file mode 100644 index 000000000..867f479a4 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/organization.rb @@ -0,0 +1,86 @@ +module ForestAdminDatasourceZendesk + module Collections + class Organization < BaseCollection + include Searchable + + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + ZENDESK_SORTABLE = { + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + 'name' => 'name' + }.freeze + + def initialize(datasource, custom_fields: []) + super(datasource, 'ZendeskOrganization') + @custom_fields = custom_fields + define_schema + define_relations + enable_search + enable_count + end + + protected + + def zendesk_resource = 'organization' + def sortable_fields = ZENDESK_SORTABLE + def find_one(id) = datasource.client.find_organization(id) + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('domain_names', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('details', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('notes', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('group_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('shared_tickets', ColumnSchema.new(column_type: 'Boolean', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + + @custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) } + end + + def define_relations + add_field('users', OneToManySchema.new( + foreign_collection: 'ZendeskUser', + origin_key: 'organization_id', + origin_key_target: 'id' + )) + add_field('tickets', OneToManySchema.new( + foreign_collection: 'ZendeskTicket', + origin_key: 'organization_id', + origin_key_target: 'id' + )) + end + + def serialize(org) + attrs = attrs_of(org) + result = base_attributes(attrs) + org_fields = attrs['organization_fields'] || {} + @custom_fields.each { |cf| result[cf[:column_name]] = org_fields[cf[:zendesk_key]] } + result + end + + def base_attributes(attrs) + { + 'id' => attrs['id'], 'name' => attrs['name'], + 'domain_names' => attrs['domain_names'], 'details' => attrs['details'], + 'notes' => attrs['notes'], 'group_id' => attrs['group_id'], + 'shared_tickets' => attrs['shared_tickets'], + 'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at'] + } + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/searchable.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/searchable.rb new file mode 100644 index 000000000..364927c50 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/searchable.rb @@ -0,0 +1,60 @@ +module ForestAdminDatasourceZendesk + module Collections + # Shared list/aggregate/find boilerplate for collections backed by the + # Zendesk Search API (currently User and Organization). Tickets have a + # custom list pipeline (relation embedding, requester_email enrichment), + # so they do *not* include this — the contract is documented in + # BaseCollection. + # + # Including classes must define: + # - `zendesk_resource` returning the Search API type (`'user'`, `'organization'`) + # - `find_one(id)` returning a single record from the Zendesk client + # - `sortable_fields` returning the {forest_field => zendesk_sort_by} map + # - `serialize(record)` mapping Zendesk attributes to a Forest hash + module Searchable + def list(caller, filter, projection) + records = ids_in_filter(filter) ? find_records_by_id(filter) : search_records(caller, filter) + records.map { |r| project(serialize(r), projection) } + end + + protected + + def aggregate_count(caller, filter) + datasource.client.count(zendesk_resource, query: compose_full_query(caller, filter)) + end + + private + + def ids_in_filter(filter) + extract_id_lookup(filter.condition_tree) + end + + def find_records_by_id(filter) + ids_in_filter(filter).filter_map { |id| find_one(id) } + end + + def search_records(caller, filter) + sort_by, sort_order = translate_sort(filter.sort, sortable_fields) + page, per_page = translate_page(filter.page) + + datasource.client.search(zendesk_resource, + query: compose_full_query(caller, filter), + sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) + end + + # Both list and count must build the query the same way: condition tree + # AND `filter.search`. A previous version of search_records omitted the + # search term, which made the count badge disagree with the rendered + # list ("100 results, count says 5") — guard against that by sharing + # this builder. + def compose_full_query(caller, filter) + translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone_for(caller), + custom_fields: datasource.custom_field_mapping + ) + [translated, filter.search].compact.reject(&:empty?).join(' ') + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/ticket.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/ticket.rb new file mode 100644 index 000000000..799eceb7d --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/ticket.rb @@ -0,0 +1,218 @@ +module ForestAdminDatasourceZendesk + module Collections + class Ticket < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + + ENUM_STATUS = %w[new open pending hold solved closed].freeze + ENUM_PRIORITY = %w[low normal high urgent].freeze + ENUM_TYPE = %w[problem incident question task].freeze + + ZENDESK_SORTABLE = { + 'updated_at' => 'updated_at', + 'created_at' => 'created_at', + 'priority' => 'priority', + 'status' => 'status', + 'ticket_type' => 'ticket_type' + }.freeze + + def initialize(datasource, custom_fields: []) + super(datasource, 'ZendeskTicket') + @custom_fields = custom_fields + define_schema + define_relations + enable_search + enable_count + end + + def list(caller, filter, projection) + records = fetch_records(filter, timezone_for(caller)) + emails = needs_requester_email?(projection) ? bulk_fetch_emails(records) : {} + rows = records.map { |t| project(serialize(t, emails), projection) } + embed_relations(records, rows, projection) + rows + end + + protected + + def aggregate_count(caller, filter) + datasource.client.count('ticket', query: build_query(filter, timezone_for(caller))) + end + + private + + def fetch_records(filter, timezone) + ids = extract_id_lookup(filter.condition_tree) + return ids.filter_map { |id| datasource.client.find_ticket(id) } if ids + + query = build_query(filter, timezone) + sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) + page, per_page = translate_page(filter.page) + + datasource.client.search('ticket', query: query, sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) + end + + def needs_requester_email?(projection) + projection.nil? || Array(projection).map(&:to_s).include?('requester_email') + end + + def bulk_fetch_emails(records) + ids = records.map { |t| attrs_of(t)['requester_id'] } + datasource.client.fetch_user_emails(ids) + end + + def build_query(filter, timezone) + translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone, custom_fields: datasource.custom_field_mapping + ) + [translated, filter.search].compact.reject(&:empty?).join(' ') + end + + # Embeds requester/assignee/organization (ManyToOne) when their projection + # paths are requested. Reads FK values from the source Zendesk records + # (not the projected rows, whose FK columns may have been stripped) and + # writes onto rows by index. + def embed_relations(records, rows, projection) + return if projection.nil? + + relations = relations_in(projection) + return if relations.empty? + + sources = records.map { |t| attrs_of(t) } + embed_users(rows, sources, relations) if (relations & %w[requester assignee]).any? + embed_organizations(rows, sources) if relations.include?('organization') + end + + def embed_users(rows, sources, relations) + ids = sources.flat_map { |a| [a['requester_id'], a['assignee_id']] }.compact.uniq + users = datasource.client.fetch_users_by_ids(ids) + rows.each_with_index do |row, i| + row['requester'] = serialized_user(users[sources[i]['requester_id']]) if relations.include?('requester') + row['assignee'] = serialized_user(users[sources[i]['assignee_id']]) if relations.include?('assignee') + end + end + + def embed_organizations(rows, sources) + ids = sources.filter_map { |a| a['organization_id'] }.uniq + orgs = datasource.client.fetch_organizations_by_ids(ids) + rows.each_with_index do |row, i| + row['organization'] = serialized_org(orgs[sources[i]['organization_id']]) + end + end + + def relations_in(projection) + Array(projection).map(&:to_s).filter_map { |p| p.split(':').first if p.include?(':') }.uniq + end + + def serialized_user(raw) + return nil if raw.nil? + + attrs = raw.is_a?(Hash) ? raw : attrs_of(raw) + { + 'id' => attrs['id'], 'email' => attrs['email'], 'name' => attrs['name'], + 'role' => attrs['role'], 'organization_id' => attrs['organization_id'], + 'phone' => attrs['phone'], 'time_zone' => attrs['time_zone'], + 'locale' => attrs['locale'], 'verified' => attrs['verified'], + 'suspended' => attrs['suspended'], 'created_at' => attrs['created_at'], + 'updated_at' => attrs['updated_at'] + } + end + + def serialized_org(raw) + return nil if raw.nil? + + attrs = raw.is_a?(Hash) ? raw : attrs_of(raw) + { + 'id' => attrs['id'], 'name' => attrs['name'], + 'domain_names' => attrs['domain_names'], 'details' => attrs['details'], + 'notes' => attrs['notes'], 'group_id' => attrs['group_id'], + 'shared_tickets' => attrs['shared_tickets'], + 'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at'] + } + end + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('subject', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('status', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_STATUS, is_read_only: true, is_sortable: true)) + add_field('priority', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_PRIORITY, is_read_only: true, is_sortable: true)) + add_field('ticket_type', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_TYPE, is_read_only: true, is_sortable: true)) + add_field('requester_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: true)) + add_field('assignee_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: true)) + add_field('group_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: true)) + add_field('organization_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: true)) + add_field('external_id', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('requester_email', ColumnSchema.new(column_type: 'String', filter_operators: [Operators::EQUAL], + is_read_only: true, is_sortable: false)) + add_field('tags', ColumnSchema.new(column_type: 'Json', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('url', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + + @custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) } + end + + def define_relations + add_field('requester', ManyToOneSchema.new( + foreign_collection: 'ZendeskUser', + foreign_key: 'requester_id', + foreign_key_target: 'id' + )) + add_field('assignee', ManyToOneSchema.new( + foreign_collection: 'ZendeskUser', + foreign_key: 'assignee_id', + foreign_key_target: 'id' + )) + add_field('organization', ManyToOneSchema.new( + foreign_collection: 'ZendeskOrganization', + foreign_key: 'organization_id', + foreign_key_target: 'id' + )) + add_field('comments', OneToManySchema.new( + foreign_collection: 'ZendeskComment', + origin_key: 'ticket_id', + origin_key_target: 'id' + )) + end + + def serialize(ticket, emails = {}) + attrs = attrs_of(ticket) + result = base_attributes(attrs, emails) + cf_values = Array(attrs['custom_fields']).to_h { |f| [f['id'], f['value']] } + @custom_fields.each { |cf| result[cf[:column_name]] = cf_values[cf[:zendesk_id]] } + result + end + + def base_attributes(attrs, emails) + { + 'id' => attrs['id'], 'subject' => attrs['subject'], + 'description' => attrs['description'], 'status' => attrs['status'], + 'priority' => attrs['priority'], 'ticket_type' => attrs['type'], + 'requester_id' => attrs['requester_id'], 'assignee_id' => attrs['assignee_id'], + 'group_id' => attrs['group_id'], 'organization_id' => attrs['organization_id'], + 'external_id' => attrs['external_id'], + 'requester_email' => emails[attrs['requester_id']], + 'tags' => attrs['tags'], 'url' => attrs['url'], + 'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at'] + } + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/user.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/user.rb new file mode 100644 index 000000000..cf1fc3c02 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/user.rb @@ -0,0 +1,95 @@ +module ForestAdminDatasourceZendesk + module Collections + class User < BaseCollection + include Searchable + + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema + ENUM_ROLE = %w[end-user agent admin].freeze + + ZENDESK_SORTABLE = { + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + 'name' => 'name' + }.freeze + + def initialize(datasource, custom_fields: []) + super(datasource, 'ZendeskUser') + @custom_fields = custom_fields + define_schema + define_relations + enable_search + enable_count + end + + protected + + def zendesk_resource = 'user' + def sortable_fields = ZENDESK_SORTABLE + def find_one(id) = datasource.client.find_user(id) + + private + + def define_schema + add_field('id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_primary_key: true, is_read_only: true, is_sortable: true)) + add_field('email', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('name', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: true)) + add_field('role', ColumnSchema.new(column_type: 'Enum', filter_operators: STRING_OPS, + enum_values: ENUM_ROLE, is_read_only: true, is_sortable: false)) + add_field('phone', ColumnSchema.new(column_type: 'String', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('organization_id', ColumnSchema.new(column_type: 'Number', filter_operators: NUMBER_OPS, + is_read_only: true, is_sortable: false)) + add_field('time_zone', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('locale', ColumnSchema.new(column_type: 'String', filter_operators: [], + is_read_only: true, is_sortable: false)) + add_field('verified', ColumnSchema.new(column_type: 'Boolean', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('suspended', ColumnSchema.new(column_type: 'Boolean', filter_operators: STRING_OPS, + is_read_only: true, is_sortable: false)) + add_field('created_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + add_field('updated_at', ColumnSchema.new(column_type: 'Date', filter_operators: DATE_OPS, + is_read_only: true, is_sortable: true)) + + @custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) } + end + + def define_relations + add_field('organization', ManyToOneSchema.new( + foreign_collection: 'ZendeskOrganization', + foreign_key: 'organization_id', + foreign_key_target: 'id' + )) + add_field('requested_tickets', OneToManySchema.new( + foreign_collection: 'ZendeskTicket', + origin_key: 'requester_id', + origin_key_target: 'id' + )) + end + + def serialize(user) + attrs = attrs_of(user) + result = base_attributes(attrs) + user_fields = attrs['user_fields'] || {} + @custom_fields.each { |cf| result[cf[:column_name]] = user_fields[cf[:zendesk_key]] } + result + end + + def base_attributes(attrs) + { + 'id' => attrs['id'], 'email' => attrs['email'], 'name' => attrs['name'], + 'role' => attrs['role'], 'phone' => attrs['phone'], + 'organization_id' => attrs['organization_id'], + 'time_zone' => attrs['time_zone'], 'locale' => attrs['locale'], + 'verified' => attrs['verified'], 'suspended' => attrs['suspended'], + 'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at'] + } + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/configuration.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/configuration.rb new file mode 100644 index 000000000..f3c26dc8e --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/configuration.rb @@ -0,0 +1,33 @@ +module ForestAdminDatasourceZendesk + class Configuration + attr_reader :subdomain, :username, :token + + def initialize(subdomain:, username:, token:) + @subdomain = subdomain + @username = username + @token = token + validate! + end + + def url + "https://#{@subdomain}.zendesk.com/api/v2" + end + + private + + def validate! + missing = [] + missing << 'subdomain' if blank?(@subdomain) + missing << 'username' if blank?(@username) + missing << 'token' if blank?(@token) + return if missing.empty? + + raise ConfigurationError, + "ForestAdminDatasourceZendesk missing required config: #{missing.join(", ")}" + end + + def blank?(value) + value.nil? || value.to_s.strip.empty? + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/datasource.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/datasource.rb new file mode 100644 index 000000000..4f961c8eb --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/datasource.rb @@ -0,0 +1,47 @@ +module ForestAdminDatasourceZendesk + class Datasource < ForestAdminDatasourceToolkit::Datasource + attr_reader :client, :configuration, :custom_field_mapping + + def initialize(subdomain:, username:, token:) + super() + @configuration = Configuration.new(subdomain: subdomain, username: username, token: token) + @client = Client.new(@configuration) + @custom_field_mapping = {} + + register_collections + end + + private + + def register_collections + introspector = Schema::CustomFieldsIntrospector.new(@client) + + ticket_cf = introspector.ticket_custom_fields + user_cf = introspector.user_custom_fields + org_cf = introspector.organization_custom_fields + + add_collection(Collections::Ticket.new(self, custom_fields: ticket_cf)) + add_collection(Collections::User.new(self, custom_fields: user_cf)) + add_collection(Collections::Organization.new(self, custom_fields: org_cf)) + add_collection(Collections::Comment.new(self)) + + @custom_field_mapping = build_custom_field_mapping(ticket_cf, user_cf, org_cf) + end + + # Forest column name -> Zendesk Search field name. The mapping lives on + # the Datasource instance (not on the translator class) so multi-tenant + # agents with multiple Zendesk datasources don't trample each other. + # The collections pass it through to ConditionTreeTranslator.call(...). + def build_custom_field_mapping(ticket_cf, user_cf, org_cf) + mapping = {} + ticket_cf.each { |cf| mapping[cf[:column_name]] = "custom_field_#{cf[:zendesk_id]}" } + # User / org custom fields are addressed by key in Zendesk Search. + (user_cf + org_cf).each do |cf| + next unless cf[:zendesk_key] + + mapping[cf[:column_name]] ||= cf[:zendesk_key] + end + mapping + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb new file mode 100644 index 000000000..ecf889b9a --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb @@ -0,0 +1,143 @@ +require 'active_support/core_ext/time/zones' + +module ForestAdminDatasourceZendesk + module Query + # Translates a Forest ConditionTree into a Zendesk Search API query string. + # See https://developer.zendesk.com/api-reference/ticketing/ticket-management/search/ + # + # v1 supports: EQUAL, NOT_EQUAL, IN, NOT_IN, GREATER_THAN, LESS_THAN, + # BEFORE, AFTER, PRESENT, BLANK. AND aggregator only. + # Unsupported operators raise UnsupportedOperatorError so failures are + # loud, not silent wrong results. + # + # Custom-field translation: callers pass `custom_fields:` (a hash from + # Forest column names to Zendesk Search field names, owned by the + # Datasource instance) so multi-tenant agents with several Zendesk + # datasources don't trample each other's mappings. + # + # Timezone handling: callers pass `timezone:`; Date values are + # interpreted as start-of-day in that TZ, then converted to UTC. + # Time/DateTime values are converted to UTC directly (they already carry + # offset info). String values are passed through verbatim, with internal + # double quotes escaped when wrapping in quotes is needed. + class ConditionTreeTranslator + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + def self.call(condition_tree, timezone: nil, custom_fields: {}) + return '' if condition_tree.nil? + + new(timezone: timezone, custom_fields: custom_fields).translate(condition_tree) + end + + def initialize(timezone: nil, custom_fields: {}) + @timezone = timezone || 'UTC' + @custom_fields = custom_fields || {} + end + + def translate(node) + case node + when Branch then translate_branch(node) + when Leaf then translate_leaf(node) + else + raise UnsupportedOperatorError, "Unknown condition node: #{node.class}" + end + end + + private + + def translate_branch(branch) + unless branch.aggregator.to_s.casecmp('and').zero? + raise UnsupportedOperatorError, + "Zendesk Search API does not support arbitrary OR aggregation; got #{branch.aggregator.inspect}" + end + + branch.conditions.map { |c| translate(c) }.reject(&:empty?).join(' ') + end + + def translate_leaf(leaf) + field = mapped_field(leaf.field) + value = leaf.value + + return "requester:#{format_value(value)}" if leaf.field == 'requester_email' && leaf.operator == Operators::EQUAL + + case leaf.operator + when Operators::EQUAL then "#{field}:#{format_value(value)}" + when Operators::NOT_EQUAL then "-#{field}:#{format_value(value)}" + when Operators::IN then translate_in(field, value, negate: false) + when Operators::NOT_IN then translate_in(field, value, negate: true) + when Operators::GREATER_THAN, Operators::AFTER then "#{field}>#{format_value(value)}" + when Operators::LESS_THAN, Operators::BEFORE then "#{field}<#{format_value(value)}" + when Operators::PRESENT then "#{field}:*" + when Operators::BLANK then "-#{field}:*" + else + raise UnsupportedOperatorError, + "Zendesk datasource does not yet translate operator '#{leaf.operator}' on field '#{field}'" + end + end + + # `IN []` and `NOT_IN []` are nonsense filters that previously produced + # an empty string, which the branch translator dropped — silently + # turning "match nothing" into "match everything". Raise instead. + def translate_in(field, value, negate:) + values = Array(value) + if values.empty? + raise UnsupportedOperatorError, + "#{negate ? "NOT_IN" : "IN"} on field '#{field}' was given an empty array; " \ + 'pass at least one value or use the BLANK / PRESENT operators.' + end + + prefix = negate ? '-' : '' + values.map { |v| "#{prefix}#{field}:#{format_value(v)}" }.join(' ') + end + + def mapped_field(field) + @custom_fields[field] || field + end + + def format_value(value) + case value + when nil then raise_nil_value_error + when Time, DateTime then value.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + when Date then format_date(value) + when String then format_string(value) + else value.to_s + end + end + + # Forest's UI never naturally produces an EQUAL/NOT_EQUAL/IN with a nil + # value (it uses PRESENT / BLANK for that). Falling through to + # nil.to_s would emit a malformed `field:` clause that Zendesk's + # search treats as a presence check — i.e. silently the wrong query. + def raise_nil_value_error + raise UnsupportedOperatorError, + 'Filter value is nil; use the PRESENT or BLANK operator to filter for absence.' + end + + # A bare Date is interpreted as 00:00 in the caller's timezone, then + # converted to UTC. If activesupport's TZ table doesn't recognise the + # zone, fall back to UTC and log a warning. + def format_date(value) + Time.use_zone(@timezone) do + Time.zone.local(value.year, value.month, value.day).utc.strftime('%Y-%m-%dT%H:%M:%SZ') + end + rescue ArgumentError + ForestAdminDatasourceZendesk.logger.warn( + "[forest_admin_datasource_zendesk] unknown timezone '#{@timezone}', falling back to UTC" + ) + value.strftime('%Y-%m-%dT00:00:00Z') + end + + # Strings with whitespace OR internal double quotes need quoting so + # Zendesk parses them as a single phrase. We backslash-escape internal + # quotes per Zendesk's documented quoting rules; without this, a value + # like `test "with" quotes` would emit a malformed query. + def format_string(value) + return value unless value.match?(/[\s"]/) + + %("#{value.gsub('"', '\\"')}") + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb new file mode 100644 index 000000000..1b0b92a34 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb @@ -0,0 +1,122 @@ +module ForestAdminDatasourceZendesk + module Schema + # Discovers admin-defined Zendesk custom fields and produces + # ColumnSchema entries our collections can `add_field` directly. + # + # Each entry has the shape: + # { column_name: 'custom_360001234', zendesk_id: 360001234, + # zendesk_key: nil, schema: ColumnSchema } + # + # `zendesk_key` is set for user_fields/organization_fields (Zendesk + # exposes those keyed). `zendesk_id` is set for ticket_fields. + class CustomFieldsIntrospector + ColumnSchema = ForestAdminDatasourceToolkit::Schema::ColumnSchema + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + + STRING_OPS = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, + Operators::PRESENT, Operators::BLANK].freeze + NUMBER_OPS = (STRING_OPS + [Operators::GREATER_THAN, Operators::LESS_THAN]).freeze + DATE_OPS = [Operators::EQUAL, Operators::BEFORE, Operators::AFTER, + Operators::PRESENT, Operators::BLANK].freeze + + ZENDESK_TO_COLUMN_TYPE = { + 'text' => 'String', + 'textarea' => 'String', + 'regexp' => 'String', + 'partialcreditcard' => 'String', + 'integer' => 'Number', + 'decimal' => 'Number', + 'date' => 'Dateonly', + 'checkbox' => 'Boolean', + 'dropdown' => 'Enum', + 'tagger' => 'Enum', + 'multiselect' => 'Json', + 'lookup' => 'Number' + }.freeze + + def initialize(client) + @client = client + end + + def ticket_custom_fields + introspect(@client.fetch_ticket_fields, key_strategy: :ticket) + end + + def user_custom_fields + introspect(@client.fetch_user_fields, key_strategy: :user_or_org) + end + + def organization_custom_fields + introspect(@client.fetch_organization_fields, key_strategy: :user_or_org) + end + + private + + def introspect(raw_fields, key_strategy:) + Array(raw_fields) + .select { |raw| usable_field?(raw, key_strategy) } + .filter_map { |raw| build_entry(raw, key_strategy) } + end + + # System ticket fields can't be removed; skip them so we don't double-up + # the columns the Ticket schema already declares (e.g. subject/status). + # Inactive fields and unrecognized types are also skipped. + def usable_field?(raw, key_strategy) + return false unless raw['active'] + return false if key_strategy == :ticket && raw['removable'] == false + + ZENDESK_TO_COLUMN_TYPE.key?(raw['type']) + end + + def build_entry(raw, key_strategy) + column_type = ZENDESK_TO_COLUMN_TYPE.fetch(raw['type']) + name, key = column_naming(raw, key_strategy) + { column_name: name, zendesk_id: raw['id'], zendesk_key: key, + schema: build_schema(raw, column_type) } + end + + def column_naming(raw, strategy) + case strategy + when :ticket + # No reliable key on ticket_fields; use the id. + ["custom_#{raw["id"]}", nil] + when :user_or_org + key = raw['key'] || "custom_#{raw["id"]}" + [key, key] + end + end + + def build_schema(raw, column_type) + opts = { + column_type: column_type, + filter_operators: filter_operators_for(column_type), + is_read_only: true, + is_sortable: false + } + + if column_type == 'Enum' + opts[:enum_values] = Array(raw['custom_field_options']).filter_map { |o| o['value'] } + # If for some reason there are no options, drop back to String so the + # column still appears (Forest rejects empty Enum schemas). + if opts[:enum_values].empty? + opts[:column_type] = 'String' + opts[:filter_operators] = STRING_OPS + opts.delete(:enum_values) + end + end + + ColumnSchema.new(**opts) + end + + def filter_operators_for(column_type) + case column_type + when 'Number' then NUMBER_OPS + when 'Dateonly' then DATE_OPS + when 'Boolean' then [Operators::EQUAL, Operators::NOT_EQUAL] + when 'Json' then [] + else STRING_OPS + end + end + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb new file mode 100644 index 000000000..6dd3214ea --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb @@ -0,0 +1,3 @@ +module ForestAdminDatasourceZendesk + VERSION = '0.1.0'.freeze +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/client_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/client_spec.rb new file mode 100644 index 000000000..ca8bebe54 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/client_spec.rb @@ -0,0 +1,267 @@ +RSpec.describe ForestAdminDatasourceZendesk::Client do + let(:configuration) do + ForestAdminDatasourceZendesk::Configuration.new( + subdomain: 'acme', username: 'agent@acme.com/token', token: 'secret' + ) + end + let(:client) { described_class.new(configuration) } + let(:base) { 'https://acme.zendesk.com/api/v2' } + + def empty_search_response + { status: 200, body: { 'results' => [] }.to_json, + headers: { 'Content-Type' => 'application/json' } } + end + + describe '#search' do + it 'prefixes the query with type: when caller passes nothing' do + stub = stub_request(:get, "#{base}/search") + .with(query: hash_including('query' => 'type:ticket')) + .to_return(empty_search_response) + + client.search('ticket', query: '') + expect(stub).to have_been_requested + end + + it 'composes type: with the caller-supplied query' do + stub = stub_request(:get, "#{base}/search") + .with(query: hash_including('query' => 'type:user role:end-user')) + .to_return(empty_search_response) + + client.search('user', query: 'role:end-user') + expect(stub).to have_been_requested + end + + it 'caps per_page at MAX_PER_PAGE' do + stub = stub_request(:get, "#{base}/search") + .with(query: hash_including('per_page' => '100')) + .to_return(empty_search_response) + + client.search('ticket', query: '', per_page: 999) + expect(stub).to have_been_requested + end + + it 'forwards sort_by and sort_order' do + stub = stub_request(:get, "#{base}/search") + .with(query: hash_including('sort_by' => 'updated_at', 'sort_order' => 'desc')) + .to_return(empty_search_response) + + client.search('ticket', query: '', sort_by: 'updated_at', sort_order: 'desc') + expect(stub).to have_been_requested + end + end + + describe '#count' do + it 'calls /search/count and returns body["count"]' do + stub_request(:get, "#{base}/search/count") + .with(query: hash_including('query' => 'type:ticket requester:a@b.com')) + .to_return(status: 200, body: { 'count' => 42 }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.count('ticket', query: 'requester:a@b.com')).to eq(42) + end + + it 'raises APIError when the API errors out (no silent zero)' do + stub_request(:get, "#{base}/search/count").with(query: hash_including({})) + .to_return(status: 500, body: 'boom') + + expect { client.count('ticket', query: '') } + .to raise_error(ForestAdminDatasourceZendesk::APIError, /count\(ticket\)/) + end + + it 'returns 0 when the body has no count key (this is a real Zendesk response shape)' do + stub_request(:get, "#{base}/search/count").with(query: hash_including({})) + .to_return(status: 200, body: '{}', headers: { 'Content-Type' => 'application/json' }) + + expect(client.count('ticket', query: '')).to eq(0) + end + end + + describe '#find_ticket' do + it 'fetches a single ticket by id' do + stub_request(:get, "#{base}/tickets/123") + .to_return(status: 200, + body: { 'ticket' => { 'id' => 123, 'subject' => 'Hi' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + ticket = client.find_ticket(123) + expect(ticket.id).to eq(123) + expect(ticket.subject).to eq('Hi') + end + + it 'returns nil on 404' do + stub_request(:get, "#{base}/tickets/999").to_return(status: 404, body: '{}') + expect(client.find_ticket(999)).to be_nil + end + end + + describe '#find_user' do + it 'fetches a single user by id' do + stub_request(:get, "#{base}/users/7") + .to_return(status: 200, + body: { 'user' => { 'id' => 7, 'email' => 'a@b.com' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.find_user(7).email).to eq('a@b.com') + end + + it 'returns nil on 404' do + stub_request(:get, "#{base}/users/999").to_return(status: 404, body: '{}') + expect(client.find_user(999)).to be_nil + end + end + + describe '#find_organization' do + it 'fetches a single organization by id' do + stub_request(:get, "#{base}/organizations/12") + .to_return(status: 200, + body: { 'organization' => { 'id' => 12, 'name' => 'Acme' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.find_organization(12).name).to eq('Acme') + end + end + + describe '#fetch_ticket_comments' do + it 'returns the comments array' do + stub_request(:get, "#{base}/tickets/42/comments") + .to_return(status: 200, + body: { 'comments' => [{ 'id' => 1, 'body' => 'hi' }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + result = client.fetch_ticket_comments(42) + expect(result).to eq([{ 'id' => 1, 'body' => 'hi' }]) + end + + it 'raises APIError on failure (no silent empty list)' do + stub_request(:get, "#{base}/tickets/42/comments").to_return(status: 500, body: 'boom') + expect { client.fetch_ticket_comments(42) } + .to raise_error(ForestAdminDatasourceZendesk::APIError, /fetch_ticket_comments\(42\)/) + end + end + + describe '#fetch_user_emails' do + it 'returns {} for empty input' do + expect(client.fetch_user_emails([])).to eq({}) + expect(client.fetch_user_emails(nil)).to eq({}) + expect(WebMock).not_to have_requested(:any, /.*/) + end + + it 'maps user ids to emails via /users/show_many' do + stub_request(:get, "#{base}/users/show_many") + .with(query: hash_including('ids' => '1,2,3')) + .to_return(status: 200, + body: { 'users' => [ + { 'id' => 1, 'email' => 'a@x.com' }, + { 'id' => 2, 'email' => 'b@x.com' }, + { 'id' => 3, 'email' => nil } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.fetch_user_emails([1, 2, 3])).to eq(1 => 'a@x.com', 2 => 'b@x.com', 3 => nil) + end + + it 'batches ids in groups of 100' do + ids = (1..150).to_a + first = stub_request(:get, "#{base}/users/show_many").with(query: hash_including('ids' => (1..100).to_a.join(','))) + .to_return(status: 200, body: { 'users' => [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + second = stub_request(:get, "#{base}/users/show_many").with(query: hash_including('ids' => (101..150).to_a.join(','))) + .to_return(status: 200, body: { 'users' => [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + client.fetch_user_emails(ids) + expect(first).to have_been_requested + expect(second).to have_been_requested + end + + it 'returns {} if the bulk endpoint errors' do + stub_request(:get, "#{base}/users/show_many").with(query: hash_including({})) + .to_return(status: 500, body: 'boom') + expect(client.fetch_user_emails([1])).to eq({}) + end + end + + describe '#fetch_users_by_ids' do + it 'returns id -> full user hash' do + stub_request(:get, "#{base}/users/show_many") + .with(query: hash_including('ids' => '1,2')) + .to_return(status: 200, + body: { 'users' => [ + { 'id' => 1, 'email' => 'a@x.com', 'name' => 'A' }, + { 'id' => 2, 'email' => 'b@x.com', 'name' => 'B' } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + result = client.fetch_users_by_ids([1, 2]) + expect(result.keys).to eq([1, 2]) + expect(result[1]['name']).to eq('A') + end + + it 'returns {} on error' do + stub_request(:get, "#{base}/users/show_many").with(query: hash_including({})) + .to_return(status: 500, body: 'boom') + expect(client.fetch_users_by_ids([1])).to eq({}) + end + end + + describe '#fetch_organizations_by_ids' do + it 'returns id -> organization hash' do + stub_request(:get, "#{base}/organizations/show_many") + .with(query: hash_including('ids' => '5')) + .to_return(status: 200, + body: { 'organizations' => [{ 'id' => 5, 'name' => 'Acme' }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + expect(client.fetch_organizations_by_ids([5])[5]['name']).to eq('Acme') + end + end + + describe 'best-effort logging on degradation paths' do + let(:logger) { instance_double(Logger, warn: nil) } + + before { ForestAdminDatasourceZendesk.logger = logger } + after { ForestAdminDatasourceZendesk.logger = nil } + + it 'logs a warning when fetch_user_emails fails and returns {}' do + stub_request(:get, "#{base}/users/show_many").with(query: hash_including({})) + .to_return(status: 500, body: 'boom') + + expect(logger).to receive(:warn).with(/fetch_user_emails failed; degrading/) + expect(client.fetch_user_emails([1])).to eq({}) + end + + it 'logs a warning when introspection fails at boot and returns []' do + stub_request(:get, "#{base}/ticket_fields").to_return(status: 500, body: 'boom') + + expect(logger).to receive(:warn).with(/fetch_ticket_fields.*custom fields will be unavailable/) + expect(client.fetch_ticket_fields).to eq([]) + end + end + + describe 'schema introspection endpoints' do + it 'fetches ticket_fields' do + stub_request(:get, "#{base}/ticket_fields") + .to_return(status: 200, body: { 'ticket_fields' => [{ 'id' => 1, 'type' => 'text' }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + expect(client.fetch_ticket_fields).to eq([{ 'id' => 1, 'type' => 'text' }]) + end + + it 'returns [] on error for ticket_fields' do + stub_request(:get, "#{base}/ticket_fields").to_return(status: 500, body: 'boom') + expect(client.fetch_ticket_fields).to eq([]) + end + + it 'fetches user_fields' do + stub_request(:get, "#{base}/user_fields") + .to_return(status: 200, body: { 'user_fields' => [{ 'key' => 'tier' }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + expect(client.fetch_user_fields).to eq([{ 'key' => 'tier' }]) + end + + it 'fetches organization_fields' do + stub_request(:get, "#{base}/organization_fields") + .to_return(status: 200, body: { 'organization_fields' => [{ 'key' => 'plan' }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + expect(client.fetch_organization_fields).to eq([{ 'key' => 'plan' }]) + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/comment_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/comment_spec.rb new file mode 100644 index 000000000..f5de744c0 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/comment_spec.rb @@ -0,0 +1,114 @@ +RSpec.describe ForestAdminDatasourceZendesk::Collections::Comment do + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + + let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } + let(:datasource) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + let(:collection) { described_class.new(datasource) } + + describe 'schema' do + it 'declares core comment fields and Author/Ticket ManyToOne relations' do + keys = collection.schema[:fields].keys + expect(keys).to include('id', 'ticket_id', 'author_id', 'body', 'public', 'author', 'ticket') + expect(collection.schema[:fields]['author']) + .to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + + it 'uses a synthetic single PK (String) to side-step forest_admin_rails URL constraints' do + expect(collection.schema[:fields]['id'].is_primary_key).to be(true) + expect(collection.schema[:fields]['id'].column_type).to eq('String') + expect(collection.schema[:fields]['ticket_id'].is_primary_key).to be(false) + end + + it 'leaves count disabled (Zendesk has no count endpoint for comments)' do + expect(collection.is_countable?).to be(false) + end + end + + describe '#list' do + it 'fetches comments by parent ticket_id (EQUAL); synthesizes id as -' do + expect(client).to receive(:fetch_ticket_comments).with(42).and_return([ + { 'id' => 1, 'body' => 'hi', 'author_id' => 7, 'public' => true, + 'created_at' => '2026-01-01' } + ]) + + filter = Filter.new(condition_tree: Leaf.new('ticket_id', 'equal', 42)) + result = collection.list(nil, filter, nil) + expect(result.first).to include('id' => '1-42', 'body' => 'hi', 'ticket_id' => 42) + end + + it 'fetches comments for every ticket_id in IN list' do + allow(client).to receive(:fetch_ticket_comments).with(1).and_return([{ 'id' => 100 }]) + allow(client).to receive(:fetch_ticket_comments).with(2).and_return([{ 'id' => 200 }]) + + filter = Filter.new(condition_tree: Leaf.new('ticket_id', 'in', [1, 2])) + result = collection.list(nil, filter, ['id']) + expect(result.map { |r| r['id'] }).to eq(['100-1', '200-2']) + end + + it 'returns [] when filter targets a non-ticket_id field (top-level browse)' do + expect(client).not_to receive(:fetch_ticket_comments) + result = collection.list(nil, + Filter.new(condition_tree: Leaf.new('public', 'equal', true)), nil) + expect(result).to eq([]) + end + + it 'returns [] when there is no condition tree at all (top-level browse)' do + expect(client).not_to receive(:fetch_ticket_comments) + expect(collection.list(nil, Filter.new, nil)).to eq([]) + end + + it 'returns [] when ticket_id uses an unsupported operator' do + expect(client).not_to receive(:fetch_ticket_comments) + filter = Filter.new(condition_tree: Leaf.new('ticket_id', 'greater_than', 1)) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'fetches a single comment via the synthetic id (show route: id = "-")' do + expect(client).to receive(:fetch_ticket_comments).with(226).and_return([ + { 'id' => 1, 'body' => 'first' }, + { 'id' => 2, 'body' => 'second' } + ]) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', '2-226')) + result = collection.list(nil, filter, nil) + expect(result.size).to eq(1) + expect(result.first['id']).to eq('2-226') + expect(result.first['body']).to eq('second') + end + + it 'returns [] when synthetic id is malformed (no dash)' do + expect(client).not_to receive(:fetch_ticket_comments) + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'garbage')) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'returns [] when only the comment_id half is invalid (e.g., "abc-456")' do + # Regression: previously the malformed `abc-456` synthetic id + # contributed its valid `456` ticket_id to the scope, then fetched ALL + # comments for ticket 456 — wrong, because the row the user clicked + # doesn't actually exist. + expect(client).not_to receive(:fetch_ticket_comments) + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 'abc-456')) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'returns [] when only the ticket_id half is invalid (e.g., "1-bad")' do + expect(client).not_to receive(:fetch_ticket_comments) + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', '1-bad')) + expect(collection.list(nil, filter, nil)).to eq([]) + end + + it 'flattens via.channel into via_channel' do + expect(client).to receive(:fetch_ticket_comments).with(1).and_return([ + { 'id' => 99, + 'via' => { 'channel' => 'web' } } + ]) + + filter = Filter.new(condition_tree: Leaf.new('ticket_id', 'equal', 1)) + result = collection.list(nil, filter, nil).first + expect(result['via_channel']).to eq('web') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/organization_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/organization_spec.rb new file mode 100644 index 000000000..3ed6515ad --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/organization_spec.rb @@ -0,0 +1,79 @@ +RSpec.describe ForestAdminDatasourceZendesk::Collections::Organization do + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceZendesk::Datasource, + client: client, custom_field_mapping: {}) + end + let(:collection) { described_class.new(datasource) } + + def zendesk_org(attrs) + Struct.new(:attributes).new(attrs) + end + + describe 'schema' do + it 'declares core fields and OneToMany relations to users and tickets' do + keys = collection.schema[:fields].keys + expect(keys).to include('id', 'name', 'domain_names', 'users', 'tickets') + expect(collection.schema[:fields]['users']) + .to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + end + end + + describe '#list' do + it 'short-circuits to find_organization on id lookup' do + org = zendesk_org('id' => 5, 'name' => 'Acme') + expect(client).to receive(:find_organization).with(5).and_return(org) + expect(client).not_to receive(:search) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 5)) + expect(collection.list(nil, filter, ['id', 'name']).first['name']).to eq('Acme') + end + + it 'searches with type:organization otherwise' do + expect(client).to receive(:search) do |type, args| + expect(type).to eq('organization') + expect(args[:query]).to eq('name:Acme') + [] + end + + filter = Filter.new(condition_tree: Leaf.new('name', 'equal', 'Acme')) + collection.list(nil, filter, ['id']) + end + end + + describe '#aggregate' do + it 'counts via type:organization' do + expect(client).to receive(:count).with('organization', query: '').and_return(5) + result = collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(5) + end + + it 'raises on unsupported aggregations' do + expect do + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end + end + + describe 'custom fields' do + let(:cf) do + [{ column_name: 'plan', zendesk_id: 2, zendesk_key: 'plan', + schema: ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: 'String', filter_operators: [], is_read_only: true + ) }] + end + let(:collection) { described_class.new(datasource, custom_fields: cf) } + + it 'serializes organization_fields[key] under the column name' do + org = zendesk_org('id' => 1, 'organization_fields' => { 'plan' => 'enterprise' }) + expect(client).to receive(:search).and_return([org]) + + result = collection.list(nil, Filter.new, nil).first + expect(result['plan']).to eq('enterprise') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/ticket_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/ticket_spec.rb new file mode 100644 index 000000000..1c9764744 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/ticket_spec.rb @@ -0,0 +1,378 @@ +RSpec.describe ForestAdminDatasourceZendesk::Collections::Ticket do + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Page = ForestAdminDatasourceToolkit::Components::Query::Page + Sort = ForestAdminDatasourceToolkit::Components::Query::Sort + Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceZendesk::Datasource, + client: client, custom_field_mapping: {}) + end + let(:collection) { described_class.new(datasource) } + + def zendesk_record(attrs) + Struct.new(:attributes).new(attrs) + end + + describe 'schema' do + it 'declares the canonical ticket fields' do + expect(collection.schema[:fields].keys).to include( + 'id', 'subject', 'description', 'status', 'priority', 'ticket_type', + 'requester_id', 'assignee_id', 'group_id', 'organization_id', + 'external_id', 'requester_email', 'tags', 'url', 'created_at', 'updated_at', + 'requester', 'assignee', 'organization', 'comments' + ) + end + + it 'marks id as primary key' do + expect(collection.schema[:fields]['id'].is_primary_key).to be(true) + end + + it 'enables search and count' do + expect(collection.is_searchable?).to be(true) + expect(collection.is_countable?).to be(true) + end + + it 'declares ManyToOne relations to User and Organization' do + expect(collection.schema[:fields]['requester']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(collection.schema[:fields]['organization']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + + it 'declares OneToMany comments' do + expect(collection.schema[:fields]['comments']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + end + end + + describe '#list' do + let(:tickets) { [zendesk_record('id' => 1, 'subject' => 'a', 'requester_id' => 10)] } + + context 'when filtering by id (PK lookup)' do + it 'short-circuits to find_ticket' do + ticket = zendesk_record('id' => 215, 'subject' => 'show', 'requester_id' => 10) + allow(client).to receive(:fetch_user_emails).with([10]).and_return(10 => 'x@y.com') + expect(client).to receive(:find_ticket).with(215).and_return(ticket) + expect(client).not_to receive(:search) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 215)) + result = collection.list(nil, filter, %w[id subject requester_email]) + + expect(result.first['id']).to eq(215) + expect(result.first['requester_email']).to eq('x@y.com') + end + + it 'fetches every id in an IN list' do + expect(client).to receive(:find_ticket).with(1).and_return(zendesk_record('id' => 1, 'requester_id' => nil)) + expect(client).to receive(:find_ticket).with(2).and_return(zendesk_record('id' => 2, 'requester_id' => nil)) + allow(client).to receive(:fetch_user_emails).and_return({}) + + filter = Filter.new(condition_tree: Leaf.new('id', 'in', [1, 2])) + expect(collection.list(nil, filter, ['id']).map { |r| r['id'] }).to eq([1, 2]) + end + + it 'falls back to search when the id leaf uses an unsupported operator' do + expect(client).to receive(:search).and_return([]) + expect(client).not_to receive(:find_ticket) + + filter = Filter.new(condition_tree: Leaf.new('id', 'greater_than', 100)) + collection.list(nil, filter, ['id']) + end + end + + context 'when filtering by other fields' do + it 'translates the condition tree to a Zendesk Search query' do + expect(client).to receive(:search) do |type, args| + expect(type).to eq('ticket') + expect(args[:query]).to eq('status:open') + tickets + end + allow(client).to receive(:fetch_user_emails).and_return(10 => 'a@b.com') + + filter = Filter.new(condition_tree: Leaf.new('status', 'equal', 'open')) + collection.list(nil, filter, ['id', 'subject']) + end + + it 'merges filter.search into the query string' do + expect(client).to receive(:search) do |_type, args| + expect(args[:query]).to eq('status:open password reset') + [] + end + + filter = Filter.new( + condition_tree: Leaf.new('status', 'equal', 'open'), + search: 'password reset' + ) + collection.list(nil, filter, ['id']) + end + + it 'forwards sort_by and sort_order' do + expect(client).to receive(:search) do |_type, args| + expect(args[:sort_by]).to eq('created_at') + expect(args[:sort_order]).to eq('asc') + [] + end + + filter = Filter.new(sort: Sort.new([{ field: 'created_at', ascending: true }])) + collection.list(nil, filter, ['id']) + end + + it 'sends sort_order=desc when sort.ascending is false' do + expect(client).to receive(:search) do |_type, args| + expect(args[:sort_order]).to eq('desc') + [] + end + + filter = Filter.new(sort: Sort.new([{ field: 'updated_at', ascending: false }])) + collection.list(nil, filter, ['id']) + end + + it 'preserves ascending=false even when both symbol and string keys are present' do + # Regression: `entry[:ascending] || entry['ascending']` would silently + # flip a descending sort to ascending if both keys existed with + # different values. + expect(client).to receive(:search) do |_type, args| + expect(args[:sort_order]).to eq('desc') + [] + end + + filter = Filter.new(sort: Sort.new([{ field: 'updated_at', ascending: false, + 'ascending' => true }])) + collection.list(nil, filter, ['id']) + end + + it 'returns no sort_by when the field is not in Zendesk allow-list' do + expect(client).to receive(:search) do |_type, args| + expect(args[:sort_by]).to be_nil + expect(args[:sort_order]).to be_nil + [] + end + + filter = Filter.new(sort: Sort.new([{ field: 'subject', ascending: true }])) + collection.list(nil, filter, ['id']) + end + + it 'translates page offset/limit to Zendesk page/per_page' do + expect(client).to receive(:search) do |_type, args| + expect(args[:page]).to eq(3) + expect(args[:per_page]).to eq(15) + [] + end + + filter = Filter.new(page: Page.new(offset: 30, limit: 15)) + collection.list(nil, filter, ['id']) + end + + it 'falls back to MAX_PER_PAGE when page.limit is nil' do + expect(client).to receive(:search) do |_type, args| + expect(args[:per_page]).to eq(ForestAdminDatasourceZendesk::Client::MAX_PER_PAGE) + [] + end + + filter = Filter.new(page: Page.new(offset: 0, limit: nil)) + collection.list(nil, filter, ['id']) + end + end + + describe 'requester_email enrichment' do + before do + allow(client).to receive(:search).and_return([ + zendesk_record('id' => 1, 'requester_id' => 10), + zendesk_record('id' => 2, 'requester_id' => 10), + zendesk_record('id' => 3, 'requester_id' => 20) + ]) + end + + it 'bulk-fetches emails when requester_email is in the projection' do + expect(client).to receive(:fetch_user_emails) + .with([10, 10, 20]).and_return(10 => 'a@b.com', 20 => 'c@d.com') + + result = collection.list(nil, Filter.new, ['id', 'requester_email']) + expect(result.map { |r| r['requester_email'] }).to eq(%w[a@b.com a@b.com c@d.com]) + end + + it 'skips bulk fetch when requester_email is not requested' do + expect(client).not_to receive(:fetch_user_emails) + collection.list(nil, Filter.new, ['id', 'subject']) + end + + it 'fetches by default when projection is nil' do + expect(client).to receive(:fetch_user_emails).and_return({}) + collection.list(nil, Filter.new, nil) + end + end + + describe 'relation embedding (ManyToOne)' do + let(:tickets) do + [zendesk_record('id' => 1, 'requester_id' => 10, 'assignee_id' => 11, 'organization_id' => 50)] + end + + before do + allow(client).to receive(:search).and_return(tickets) + end + + it 'embeds requester when requester:* is in projection' do + allow(client).to receive(:fetch_user_emails).and_return({}) + expect(client).to receive(:fetch_users_by_ids) + .with([10, 11]) + .and_return(10 => { 'id' => 10, 'email' => 'r@x.com', 'name' => 'R' }) + + result = collection.list(nil, Filter.new, ['id', 'requester:id', 'requester:email']) + expect(result.first['requester']).to include('id' => 10, 'email' => 'r@x.com') + end + + it 'embeds organization when organization:* is in projection' do + allow(client).to receive(:fetch_user_emails).and_return({}) + expect(client).to receive(:fetch_organizations_by_ids) + .with([50]) + .and_return(50 => { 'id' => 50, 'name' => 'Acme' }) + + result = collection.list(nil, Filter.new, ['id', 'organization:id', 'organization:name']) + expect(result.first['organization']).to include('id' => 50, 'name' => 'Acme') + end + + it 'skips relation fetches when only column projection is requested' do + allow(client).to receive(:fetch_user_emails).and_return({}) + expect(client).not_to receive(:fetch_users_by_ids) + expect(client).not_to receive(:fetch_organizations_by_ids) + + collection.list(nil, Filter.new, ['id', 'subject']) + end + + it 'embeds only requester (no assignee) when only requester:* is in projection' do + allow(client).to receive_messages(fetch_user_emails: {}, fetch_users_by_ids: { 10 => { 'id' => 10 } }) + + result = collection.list(nil, Filter.new, ['id', 'requester:id']).first + expect(result).to have_key('requester') + expect(result).not_to have_key('assignee') + end + + it 'fetches users but only embeds assignee when only assignee:* is requested' do + allow(client).to receive_messages(fetch_user_emails: {}, fetch_users_by_ids: { 11 => { 'id' => 11 } }) + + result = collection.list(nil, Filter.new, ['id', 'assignee:id']).first + expect(result).to have_key('assignee') + expect(result).not_to have_key('requester') + end + + it 'leaves the relation hash nil when the foreign id is not resolvable' do + allow(client).to receive_messages(search: [ + zendesk_record('id' => 9, 'requester_id' => 999, + 'assignee_id' => nil, 'organization_id' => nil) + ], fetch_user_emails: {}, fetch_users_by_ids: {}) + + result = collection.list(nil, Filter.new, ['id', 'requester:id']).first + expect(result['requester']).to be_nil + end + end + + describe 'projection edge cases' do + it 'returns the full record when projection is nil' do + allow(client).to receive_messages(search: [zendesk_record('id' => 1, 'requester_id' => nil)], + fetch_user_emails: {}) + + record = collection.list(nil, Filter.new, nil).first + expect(record.keys).to include('id', 'subject', 'status') + end + + it 'falls back to to_h when ticket lacks attributes' do + plain_hash_ticket = { 'id' => 1, 'subject' => 'plain', 'requester_id' => nil } + allow(client).to receive_messages(search: [plain_hash_ticket], fetch_user_emails: {}) + + result = collection.list(nil, Filter.new, nil).first + expect(result['subject']).to eq('plain') + end + end + end + + describe 'timezone plumbing' do + Caller = ForestAdminDatasourceToolkit::Components::Caller + + let(:paris_caller) do + Caller.new(id: 1, email: 'x@x', first_name: 'X', last_name: 'Y', + tags: {}, team: 'Ops', rendering_id: 1, timezone: 'Europe/Paris', + permission_level: 'admin', role: 'Admin', request: {}) + end + + it 'feeds caller.timezone into the translator (Date filter shifts by TZ)' do + expect(client).to receive(:search) do |_type, args| + # Jan 15 in Paris is UTC+1 (no DST), so 00:00 local => 23:00 UTC previous day. + expect(args[:query]).to eq('created_at>2026-01-14T23:00:00Z') + [] + end + allow(client).to receive(:fetch_user_emails).and_return({}) + + filter = Filter.new(condition_tree: Leaf.new('created_at', 'after', Date.new(2026, 1, 15))) + collection.list(paris_caller, filter, ['id']) + end + + it 'falls back to UTC when caller is nil' do + expect(client).to receive(:search) do |_type, args| + expect(args[:query]).to eq('created_at>2026-04-27T00:00:00Z') + [] + end + allow(client).to receive(:fetch_user_emails).and_return({}) + + filter = Filter.new(condition_tree: Leaf.new('created_at', 'after', Date.new(2026, 4, 27))) + collection.list(nil, filter, ['id']) + end + + it 'falls back to UTC when caller.timezone is blank' do + blank_tz_caller = Caller.new(id: 1, email: 'x@x', first_name: 'X', last_name: 'Y', + tags: {}, team: 'Ops', rendering_id: 1, timezone: '', + permission_level: 'admin', role: 'Admin', request: {}) + expect(client).to receive(:search) do |_type, args| + expect(args[:query]).to eq('created_at>2026-04-27T00:00:00Z') + [] + end + allow(client).to receive(:fetch_user_emails).and_return({}) + + filter = Filter.new(condition_tree: Leaf.new('created_at', 'after', Date.new(2026, 4, 27))) + collection.list(blank_tz_caller, filter, ['id']) + end + end + + describe '#aggregate' do + it 'returns the count under string keys' do + expect(client).to receive(:count).with('ticket', query: 'status:open').and_return(7) + + result = collection.aggregate(nil, + Filter.new(condition_tree: Leaf.new('status', 'equal', 'open')), + Aggregation.new(operation: 'Count')) + + expect(result).to eq([{ 'value' => 7, 'group' => {} }]) + end + + it 'raises for unsupported aggregations' do + expect do + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'priority')) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end + end + + describe 'custom fields integration' do + let(:cf_schema) do + ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(column_type: 'String', + filter_operators: [], is_read_only: true) + end + let(:custom_fields) do + [{ column_name: 'custom_360001', zendesk_id: 360_001, zendesk_key: nil, schema: cf_schema }] + end + let(:collection) { described_class.new(datasource, custom_fields: custom_fields) } + + it 'adds the custom field to the schema' do + expect(collection.schema[:fields]).to have_key('custom_360001') + end + + it 'serializes the custom field value from ticket.custom_fields array' do + ticket = zendesk_record('id' => 1, 'requester_id' => nil, 'custom_fields' => [ + { 'id' => 360_001, 'value' => 'gold' }, + { 'id' => 999_999, 'value' => 'ignored' } + ]) + allow(client).to receive_messages(search: [ticket], fetch_user_emails: {}) + + result = collection.list(nil, Filter.new, nil).first + expect(result['custom_360001']).to eq('gold') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/user_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/user_spec.rb new file mode 100644 index 000000000..96513e5ab --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/user_spec.rb @@ -0,0 +1,108 @@ +RSpec.describe ForestAdminDatasourceZendesk::Collections::User do + Filter = ForestAdminDatasourceToolkit::Components::Query::Filter + Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } + let(:datasource) do + instance_double(ForestAdminDatasourceZendesk::Datasource, + client: client, custom_field_mapping: {}) + end + let(:collection) { described_class.new(datasource) } + + def zendesk_user(attrs) + Struct.new(:attributes).new(attrs) + end + + describe 'schema' do + it 'declares core user fields and relations' do + keys = collection.schema[:fields].keys + expect(keys).to include('id', 'email', 'name', 'role', 'organization_id', + 'organization', 'requested_tickets') + end + end + + describe '#list' do + it 'short-circuits to find_user on id lookup' do + user = zendesk_user('id' => 7, 'email' => 'a@b.com', 'name' => 'A') + expect(client).to receive(:find_user).with(7).and_return(user) + expect(client).not_to receive(:search) + + filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 7)) + result = collection.list(nil, filter, ['id', 'email']) + expect(result.first).to include('id' => 7, 'email' => 'a@b.com') + end + + it 'searches with type:user otherwise' do + expect(client).to receive(:search) do |type, args| + expect(type).to eq('user') + expect(args[:query]).to eq('role:admin') + [] + end + + filter = Filter.new(condition_tree: Leaf.new('role', 'equal', 'admin')) + collection.list(nil, filter, ['id']) + end + end + + describe '#aggregate' do + it 'returns string-key Count via Zendesk count endpoint with type:user' do + expect(client).to receive(:count).with('user', query: 'role:admin').and_return(3) + + result = collection.aggregate(nil, + Filter.new(condition_tree: Leaf.new('role', 'equal', 'admin')), + Aggregation.new(operation: 'Count')) + + expect(result).to eq([{ 'value' => 3, 'group' => {} }]) + end + + it 'composes the count query with both filter.condition_tree and filter.search' do + # Regression: a previous version of the search/count split fed the + # search term to count() but not to search(), causing the count badge + # to disagree with the rendered list. + expect(client).to receive(:count).with('user', query: 'role:admin pierre').and_return(2) + + result = collection.aggregate(nil, + Filter.new(condition_tree: Leaf.new('role', 'equal', 'admin'), + search: 'pierre'), + Aggregation.new(operation: 'Count')) + expect(result.first['value']).to eq(2) + end + + it 'list passes the same search term to search() that count() sees' do + expect(client).to receive(:search) do |type, **opts| + expect(type).to eq('user') + expect(opts[:query]).to eq('role:admin pierre') + [] + end + + collection.list(nil, + Filter.new(condition_tree: Leaf.new('role', 'equal', 'admin'), search: 'pierre'), + ['id']) + end + + it 'raises on unsupported aggregations' do + expect do + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end + end + + describe 'custom fields' do + let(:cf) do + [{ column_name: 'tier', zendesk_id: 1, zendesk_key: 'tier', + schema: ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: 'String', filter_operators: [], is_read_only: true + ) }] + end + let(:collection) { described_class.new(datasource, custom_fields: cf) } + + it 'serializes user_fields[key] under the column name' do + user = zendesk_user('id' => 1, 'user_fields' => { 'tier' => 'gold' }) + expect(client).to receive(:search).and_return([user]) + + result = collection.list(nil, Filter.new, nil).first + expect(result['tier']).to eq('gold') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/configuration_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/configuration_spec.rb new file mode 100644 index 000000000..6ce04e613 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/configuration_spec.rb @@ -0,0 +1,49 @@ +RSpec.describe ForestAdminDatasourceZendesk::Configuration do + let(:valid_args) do + { subdomain: 'acme', username: 'agent@acme.com/token', token: 'secret' } + end + + describe '#initialize' do + it 'accepts valid credentials' do + config = described_class.new(**valid_args) + + expect(config.subdomain).to eq('acme') + expect(config.username).to eq('agent@acme.com/token') + expect(config.token).to eq('secret') + end + + it 'raises a ConfigurationError when subdomain is nil' do + expect { described_class.new(**valid_args, subdomain: nil) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /subdomain/) + end + + it 'raises a ConfigurationError when subdomain is blank' do + expect { described_class.new(**valid_args, subdomain: ' ') } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /subdomain/) + end + + it 'raises with username when username is missing' do + expect { described_class.new(**valid_args, username: nil) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /username/) + end + + it 'raises with token when token is missing' do + expect { described_class.new(**valid_args, token: '') } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /token/) + end + + it 'lists every missing field at once' do + expect { described_class.new(subdomain: nil, username: '', token: nil) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError) { |e| + expect(e.message).to include('subdomain', 'username', 'token') + } + end + end + + describe '#url' do + it 'composes the API base URL from the subdomain' do + config = described_class.new(**valid_args) + expect(config.url).to eq('https://acme.zendesk.com/api/v2') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/datasource_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/datasource_spec.rb new file mode 100644 index 000000000..8d9162442 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/datasource_spec.rb @@ -0,0 +1,89 @@ +RSpec.describe ForestAdminDatasourceZendesk::Datasource do + let(:valid_args) { { subdomain: 'acme', username: 'agent@acme.com/token', token: 'secret' } } + let(:base) { 'https://acme.zendesk.com/api/v2' } + + before do + # Stub all introspection endpoints with empty responses (no custom fields). + %w[ticket_fields user_fields organization_fields].each do |path| + stub_request(:get, "#{base}/#{path}") + .to_return(status: 200, body: { path => [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + end + + it 'builds with valid credentials' do + ds = described_class.new(**valid_args) + expect(ds.configuration.subdomain).to eq('acme') + expect(ds.client).to be_a(ForestAdminDatasourceZendesk::Client) + end + + it 'raises ConfigurationError when credentials are missing' do + expect { described_class.new(subdomain: nil, username: '', token: nil) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError) + end + + it 'registers the four Zendesk collections' do + ds = described_class.new(**valid_args) + expect(ds.collections.keys).to contain_exactly( + 'ZendeskTicket', 'ZendeskUser', 'ZendeskOrganization', 'ZendeskComment' + ) + end + + it 'forwards discovered ticket custom fields into the Ticket schema' do + stub_request(:get, "#{base}/ticket_fields") + .to_return(status: 200, + body: { 'ticket_fields' => [ + { 'id' => 7700, 'type' => 'text', 'active' => true, 'removable' => true, 'title' => 'Tier' } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + ds = described_class.new(**valid_args) + expect(ds.get_collection('ZendeskTicket').schema[:fields]).to have_key('custom_7700') + end + + it 'exposes the ticket custom field mapping on the datasource instance' do + stub_request(:get, "#{base}/ticket_fields") + .to_return(status: 200, + body: { 'ticket_fields' => [ + { 'id' => 7700, 'type' => 'text', 'active' => true, 'removable' => true, 'title' => 'Tier' } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + ds = described_class.new(**valid_args) + expect(ds.custom_field_mapping['custom_7700']).to eq('custom_field_7700') + end + + it 'maps keyed user custom fields to their Zendesk Search key' do + stub_request(:get, "#{base}/user_fields") + .to_return(status: 200, + body: { 'user_fields' => [ + { 'id' => 1, 'key' => 'tier', 'type' => 'text', 'active' => true } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + ds = described_class.new(**valid_args) + expect(ds.custom_field_mapping['tier']).to eq('tier') + end + + it 'isolates custom field mappings between two datasource instances' do + # Multi-tenancy: each datasource owns its own mapping. + stub_request(:get, "#{base}/ticket_fields") + .to_return(status: 200, + body: { 'ticket_fields' => [ + { 'id' => 1111, 'type' => 'text', 'active' => true, 'removable' => true } + ] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + other_base = 'https://beta.zendesk.com/api/v2' + %w[ticket_fields user_fields organization_fields].each do |path| + stub_request(:get, "#{other_base}/#{path}") + .to_return(status: 200, body: { path => [] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + + a = described_class.new(**valid_args) + b = described_class.new(subdomain: 'beta', username: 'x@x/token', token: 't') + + expect(a.custom_field_mapping).to have_key('custom_1111') + expect(b.custom_field_mapping).not_to have_key('custom_1111') + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/query/condition_tree_translator_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/query/condition_tree_translator_spec.rb new file mode 100644 index 000000000..005fc5c27 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/query/condition_tree_translator_spec.rb @@ -0,0 +1,205 @@ +require 'date' + +RSpec.describe ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator do + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + + def translate(node) + described_class.call(node) + end + + it 'returns empty string when condition tree is nil' do + expect(translate(nil)).to eq('') + end + + describe 'leaf operators' do + it 'translates EQUAL' do + expect(translate(Leaf.new('status', 'equal', 'open'))).to eq('status:open') + end + + it 'translates NOT_EQUAL' do + expect(translate(Leaf.new('status', 'not_equal', 'open'))).to eq('-status:open') + end + + it 'translates IN as multiple equality clauses' do + expect(translate(Leaf.new('status', 'in', %w[open pending]))) + .to eq('status:open status:pending') + end + + it 'translates NOT_IN as multiple negated clauses' do + expect(translate(Leaf.new('status', 'not_in', %w[closed solved]))) + .to eq('-status:closed -status:solved') + end + + it 'translates GREATER_THAN' do + expect(translate(Leaf.new('priority', 'greater_than', 2))).to eq('priority>2') + end + + it 'translates LESS_THAN' do + expect(translate(Leaf.new('priority', 'less_than', 5))).to eq('priority<5') + end + + it 'translates AFTER on a Date as start-of-day in UTC by default' do + result = translate(Leaf.new('created_at', 'after', Date.new(2026, 1, 15))) + expect(result).to eq('created_at>2026-01-15T00:00:00Z') + end + + it 'translates BEFORE on a Date as start-of-day in UTC by default' do + result = translate(Leaf.new('updated_at', 'before', Date.new(2026, 4, 1))) + expect(result).to eq('updated_at<2026-04-01T00:00:00Z') + end + + it 'translates PRESENT as field:*' do + expect(translate(Leaf.new('assignee_id', 'present'))).to eq('assignee_id:*') + end + + it 'translates BLANK as -field:*' do + expect(translate(Leaf.new('assignee_id', 'blank'))).to eq('-assignee_id:*') + end + + it 'raises on unsupported operator (e.g. CONTAINS)' do + expect { translate(Leaf.new('subject', 'contains', 'foo')) } + .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /contains/) + end + end + + describe 'requester_email special case' do + it 'rewrites requester_email = X to requester:X' do + expect(translate(Leaf.new('requester_email', 'equal', 'a@b.com'))) + .to eq('requester:a@b.com') + end + + it 'falls through to generic field:value for non-equal operators on requester_email' do + # Generic EQUAL would give requester_email:VALUE; only EQUAL gets the rewrite. + expect(translate(Leaf.new('requester_email', 'present'))) + .to eq('requester_email:*') + end + end + + describe 'value formatting' do + it 'quotes string values containing spaces' do + expect(translate(Leaf.new('subject', 'equal', 'two words'))) + .to eq('subject:"two words"') + end + + it 'serialises Time as ISO8601 UTC' do + t = Time.utc(2026, 4, 27, 9, 30, 0) + expect(translate(Leaf.new('updated_at', 'after', t))) + .to eq('updated_at>2026-04-27T09:30:00Z') + end + + it 'raises rather than emit a malformed `field:` clause for nil values' do + expect { translate(Leaf.new('subject', 'equal', nil)) } + .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /PRESENT or BLANK/) + end + + it 'escapes internal double quotes when wrapping in quotes' do + # Without escaping, `test "with" quotes` would emit + # `subject:"test "with" quotes"` which Zendesk parses as malformed. + expect(translate(Leaf.new('subject', 'equal', 'test "with" quotes'))) + .to eq('subject:"test \\"with\\" quotes"') + end + + it 'still quotes a string that has only internal quotes (no whitespace)' do + expect(translate(Leaf.new('subject', 'equal', %(say"hi)))) + .to eq('subject:"say\\"hi"') + end + end + + describe 'IN / NOT_IN with empty array' do + it 'raises on IN [] (would otherwise silently match everything)' do + expect { translate(Leaf.new('status', 'in', [])) } + .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /empty array/) + end + + it 'raises on NOT_IN []' do + expect { translate(Leaf.new('status', 'not_in', [])) } + .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /empty array/) + end + end + + describe 'timezone handling' do + it 'interprets a Date as start-of-day in the supplied timezone, converted to UTC' do + # Jan 15 is outside of DST in Paris (UTC+1), so 00:00 local is 23:00 UTC the previous day. + result = described_class.call(Leaf.new('created_at', 'after', Date.new(2026, 1, 15)), + timezone: 'Europe/Paris') + expect(result).to eq('created_at>2026-01-14T23:00:00Z') + end + + it 'respects DST shifts (Apr 27 in Paris is UTC+2)' do + result = described_class.call(Leaf.new('created_at', 'after', Date.new(2026, 4, 27)), + timezone: 'Europe/Paris') + expect(result).to eq('created_at>2026-04-26T22:00:00Z') + end + + it 'falls back to UTC when the timezone is unknown' do + result = described_class.call(Leaf.new('created_at', 'after', Date.new(2026, 4, 27)), + timezone: 'Mars/Olympus_Mons') + expect(result).to eq('created_at>2026-04-27T00:00:00Z') + end + + it 'still emits Time values as UTC ISO8601 regardless of timezone arg' do + t = Time.utc(2026, 4, 27, 9, 30, 0) + result = described_class.call(Leaf.new('updated_at', 'after', t), timezone: 'Europe/Paris') + expect(result).to eq('updated_at>2026-04-27T09:30:00Z') + end + end + + describe 'custom field mapping' do + let(:mapping) { { 'custom_360001' => 'custom_field_360001' } } + + it 'rewrites a custom field column name to the Zendesk Search field' do + result = described_class.call(Leaf.new('custom_360001', 'equal', 'gold'), + custom_fields: mapping) + expect(result).to eq('custom_field_360001:gold') + end + + it 'leaves non-mapped fields untouched' do + result = described_class.call(Leaf.new('status', 'equal', 'open'), + custom_fields: mapping) + expect(result).to eq('status:open') + end + + it 'does not leak mapping between calls (no class-level state)' do + # Multi-tenant safety: a previous version stashed the mapping on the + # class. Two datasources with different mappings would step on each + # other. With per-call custom_fields, each call carries its own. + first = described_class.call(Leaf.new('custom_360001', 'equal', 'a'), + custom_fields: { 'custom_360001' => 'custom_field_111' }) + second = described_class.call(Leaf.new('custom_360001', 'equal', 'b'), + custom_fields: { 'custom_360001' => 'custom_field_222' }) + expect(first).to eq('custom_field_111:a') + expect(second).to eq('custom_field_222:b') + end + + it 'falls back to the raw field name when no mapping is supplied' do + expect(described_class.call(Leaf.new('custom_360001', 'equal', 'gold'))) + .to eq('custom_360001:gold') + end + end + + describe 'branches (aggregators)' do + it 'joins AND children with spaces' do + branch = Branch.new('And', [ + Leaf.new('status', 'equal', 'open'), + Leaf.new('priority', 'equal', 'high') + ]) + expect(translate(branch)).to eq('status:open priority:high') + end + + it 'raises on OR aggregator' do + branch = Branch.new('Or', [ + Leaf.new('status', 'equal', 'open'), + Leaf.new('status', 'equal', 'pending') + ]) + expect { translate(branch) } + .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /OR/i) + end + + it 'recurses into nested AND branches' do + inner = Branch.new('And', [Leaf.new('status', 'equal', 'open')]) + outer = Branch.new('And', [inner, Leaf.new('priority', 'equal', 'urgent')]) + expect(translate(outer)).to eq('status:open priority:urgent') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/schema/custom_fields_introspector_spec.rb b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/schema/custom_fields_introspector_spec.rb new file mode 100644 index 000000000..441043d13 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/schema/custom_fields_introspector_spec.rb @@ -0,0 +1,116 @@ +RSpec.describe ForestAdminDatasourceZendesk::Schema::CustomFieldsIntrospector do + let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } + let(:introspector) { described_class.new(client) } + + describe '#ticket_custom_fields' do + it 'maps Zendesk types to Forest column types' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 1, 'type' => 'text', 'active' => true, + 'removable' => true }, + { 'id' => 2, 'type' => 'integer', 'active' => true, + 'removable' => true }, + { 'id' => 3, 'type' => 'date', 'active' => true, + 'removable' => true }, + { 'id' => 4, 'type' => 'checkbox', 'active' => true, + 'removable' => true } + ]) + + result = introspector.ticket_custom_fields + types = result.to_h { |cf| [cf[:column_name], cf[:schema].column_type] } + expect(types).to eq( + 'custom_1' => 'String', + 'custom_2' => 'Number', + 'custom_3' => 'Dateonly', + 'custom_4' => 'Boolean' + ) + end + + it 'builds Enum schemas with custom_field_options' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 5, 'type' => 'dropdown', 'active' => true, 'removable' => true, + 'custom_field_options' => [ + { 'value' => 'gold' }, { 'value' => 'silver' } + ] } + ]) + + cf = introspector.ticket_custom_fields.first + expect(cf[:schema].column_type).to eq('Enum') + expect(cf[:schema].enum_values).to eq(%w[gold silver]) + end + + it 'maps checkbox fields with Boolean operators (EQUAL/NOT_EQUAL only)' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 4, 'type' => 'checkbox', 'active' => true, + 'removable' => true } + ]) + + cf = introspector.ticket_custom_fields.first + expect(cf[:schema].column_type).to eq('Boolean') + expect(cf[:schema].filter_operators).to eq(%w[equal not_equal]) + end + + it 'falls back to String when an Enum has no options' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 6, 'type' => 'dropdown', 'active' => true, 'removable' => true, + 'custom_field_options' => [] } + ]) + + cf = introspector.ticket_custom_fields.first + expect(cf[:schema].column_type).to eq('String') + end + + it 'skips inactive fields' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 7, 'type' => 'text', 'active' => false, + 'removable' => true } + ]) + expect(introspector.ticket_custom_fields).to be_empty + end + + it 'skips system (non-removable) fields' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 8, 'type' => 'text', 'active' => true, + 'removable' => false } + ]) + expect(introspector.ticket_custom_fields).to be_empty + end + + it 'skips fields with unrecognised types' do + allow(client).to receive(:fetch_ticket_fields).and_return([ + { 'id' => 9, 'type' => 'mystery', 'active' => true, + 'removable' => true } + ]) + expect(introspector.ticket_custom_fields).to be_empty + end + end + + describe '#user_custom_fields' do + it 'uses key (not id) for the column name when present' do + allow(client).to receive(:fetch_user_fields).and_return([ + { 'id' => 100, 'type' => 'text', 'key' => 'tier', + 'active' => true } + ]) + + result = introspector.user_custom_fields.first + expect(result[:column_name]).to eq('tier') + expect(result[:zendesk_key]).to eq('tier') + end + + it 'falls back to custom_ when key is missing' do + allow(client).to receive(:fetch_user_fields).and_return([ + { 'id' => 101, 'type' => 'text', 'active' => true } + ]) + expect(introspector.user_custom_fields.first[:column_name]).to eq('custom_101') + end + end + + describe '#organization_custom_fields' do + it 'mirrors user_custom_fields key strategy' do + allow(client).to receive(:fetch_organization_fields).and_return([ + { 'id' => 200, 'type' => 'text', + 'key' => 'plan', 'active' => true } + ]) + expect(introspector.organization_custom_fields.first[:column_name]).to eq('plan') + end + end +end diff --git a/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb b/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb new file mode 100644 index 000000000..561c180a8 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb @@ -0,0 +1,39 @@ +require 'simplecov' +# JSON output is consumed by the qlty CI coverage step; HTML is for local +# inspection. simplecov-html and simplecov_json_formatter are required only +# in Gemfile-test, so guard the require for local Gemfile runs. +begin + require 'simplecov_json_formatter' + require 'simplecov-html' + SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] +rescue LoadError + # Local Gemfile run without the CI formatters; default text output is fine. +end + +SimpleCov.start do + add_filter '/spec/' + enable_coverage :branch + minimum_coverage 90 +end + +SimpleCov.coverage_dir 'coverage' + +require 'webmock/rspec' +require 'forest_admin_datasource_zendesk' + +WebMock.disable_net_connect!(allow_localhost: true) + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end + config.mock_with :rspec do |m| + m.verify_partial_doubles = true + end + config.disable_monkey_patching! + config.warnings = false + config.order = :random + Kernel.srand config.seed + + config.before { WebMock.reset! } +end