From 39627defe5e1e4199cee7bf46bd5ee034ac6e22d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 27 Apr 2026 18:57:28 +0200 Subject: [PATCH 1/6] feat(zendesk): add Zendesk datasource package Surfaces Zendesk Tickets, Users, Organizations and ticket Comments as Forest Admin collections, mirroring how the @forestadmin/datasource-* packages work in the Node ecosystem. Highlights: * `forest_admin_datasource_zendesk` package under packages/, follows the same structure as forest_admin_datasource_active_record (Zeitwerk loader, gemspec, BaseCollection sharing sort/page/projection/PK-lookup). * Four collections: ZendeskTicket, ZendeskUser, ZendeskOrganization, ZendeskComment, with intra-datasource ManyToOne / OneToMany relations declared in the schema (Ticket -> requester/assignee/organization/ comments, Comment -> author/ticket, User -> organization/requested_tickets, Organization -> users/tickets). * Custom-field introspection at boot: queries /ticket_fields, /user_fields, /organization_fields and exposes admin-defined fields as real Forest columns. Filter translator rewrites custom column names to Zendesk Search syntax (`custom_field_` for tickets; key for keyed user/org fields). * ConditionTreeTranslator produces Zendesk Search queries with caller-timezone-aware Date interpretation (Date is start-of-day in the caller's TZ, converted to UTC; DST-aware via ActiveSupport). * Loud errors on critical paths (search, count, fetch_ticket_comments) via wrapped APIError; best-effort logging + safe defaults on enrichment paths (bulk user/org lookups, schema introspection). * ZendeskComment uses a synthetic single-PK String (`-`) to side-step forest_admin_rails 1.26.2's URL constraint, which rejects the `|` character used by the toolkit's native pack_id for composite keys. * Standalone ZendeskComment list returns [] when no ticket scope is supplied (Zendesk has no /comments listing endpoint). Tests: 131 examples, 98.5% line / 90.7% branch coverage. WebMock blocks real network. Run with `bundle exec rspec` from the package directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.gitignore | 7 + .../forest_admin_datasource_zendesk/.rspec | 3 + .../forest_admin_datasource_zendesk/Gemfile | 13 + .../forest_admin_datasource_zendesk/Rakefile | 6 + .../forest_admin_datasource_zendesk.gemspec | 35 ++ .../lib/forest_admin_datasource_zendesk.rb | 35 ++ .../forest_admin_datasource_zendesk/client.rb | 158 ++++++++ .../collections/base_collection.rb | 76 ++++ .../collections/comment.rb | 153 ++++++++ .../collections/organization.rb | 115 ++++++ .../collections/ticket.rb | 233 +++++++++++ .../collections/user.rb | 128 ++++++ .../configuration.rb | 33 ++ .../datasource.rb | 46 +++ .../query/condition_tree_translator.rb | 113 ++++++ .../schema/custom_fields_introspector.rb | 115 ++++++ .../version.rb | 3 + .../client_spec.rb | 267 +++++++++++++ .../collections/comment_spec.rb | 97 +++++ .../collections/organization_spec.rb | 75 ++++ .../collections/ticket_spec.rb | 367 ++++++++++++++++++ .../collections/user_spec.rb | 80 ++++ .../configuration_spec.rb | 49 +++ .../datasource_spec.rb | 69 ++++ .../query/condition_tree_translator_spec.rb | 161 ++++++++ .../schema/custom_fields_introspector_spec.rb | 106 +++++ .../spec/spec_helper.rb | 26 ++ 27 files changed, 2569 insertions(+) create mode 100644 packages/forest_admin_datasource_zendesk/.gitignore create mode 100644 packages/forest_admin_datasource_zendesk/.rspec create mode 100644 packages/forest_admin_datasource_zendesk/Gemfile create mode 100644 packages/forest_admin_datasource_zendesk/Rakefile create mode 100644 packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/client.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/comment.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/organization.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/ticket.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/user.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/configuration.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/datasource.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/version.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/client_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/comment_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/organization_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/ticket_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/user_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/configuration_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/datasource_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/query/condition_tree_translator_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/schema/custom_fields_introspector_spec.rb create mode 100644 packages/forest_admin_datasource_zendesk/spec/spec_helper.rb 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/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/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..c83dd0176 --- /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'] = 'false' + + 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..95a644213 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/client.rb @@ -0,0 +1,158 @@ +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) ---------- + + def search(type, query:, sort_by: nil, sort_order: nil, page: 1, per_page: MAX_PER_PAGE) + params = { + query: compose_query(type, query), + per_page: [per_page, MAX_PER_PAGE].min, + page: page + } + params[:sort_by] = sort_by if sort_by + params[:sort_order] = sort_order if sort_order + + 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).each_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 ZendeskAPI::Error::RecordNotFound => e + # Bubble untouched; these are domain-level "not found" signals callers + # of find_* already handle. For search/count this shouldn't happen, but + # if it does we'd want it visible. + raise APIError, "Zendesk API call failed: #{operation}: #{e.class}: #{e.message}" + rescue StandardError => e + 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 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..9f87c18e5 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/base_collection.rb @@ -0,0 +1,76 @@ +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 + + protected + + # 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? + + first = sort.first + field = first.respond_to?(:field) ? first.field : first[:field] || first['field'] + ascending = first.respond_to?(:ascending) ? first.ascending : (first[:ascending] || first['ascending']) + 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 && 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 + 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..3bcc72d81 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/comment.rb @@ -0,0 +1,153 @@ +module ForestAdminDatasourceZendesk + module Collections + # Comments are *always* fetched in the context of a parent ticket + # (Zendesk: GET /tickets/{id}/comments). The collection only supports + # filters of the form `ticket_id = N` or `ticket_id IN [...]`. Anything + # else raises ForestException. + class Comment < BaseCollection + ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + + def initialize(datasource) + super(datasource, 'ZendeskComment') + define_schema + define_relations + end + + def list(_caller, filter, projection) + synthetic_ids = extract_field_lookup(filter.condition_tree, 'id') + ticket_ids = extract_field_lookup(filter.condition_tree, 'ticket_id') || [] + comment_ids = [] + + Array(synthetic_ids).each do |sid| + c_id, t_id = decode_synthetic_id(sid) + comment_ids << c_id if c_id + ticket_ids << t_id if t_id + end + + ticket_ids.uniq! + comment_ids = comment_ids.empty? ? nil : comment_ids.uniq + + # Top-level browse with no ticket scope -> return []. Zendesk has no + # /comments listing endpoint; legitimate access (Ticket -> comments + # relation, show route via synthetic id) always carries a ticket_id. + 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 = 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 + + records.map { |c| project(serialize(c), projection) } + end + + # Counts are deactivated for comments — Zendesk doesn't expose a count + # endpoint for ticket comments, and the list endpoint already returns + # everything in a single response. + + private + + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + + # 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 } + return nil if leaves.empty? + + values = leaves.flat_map do |leaf| + case leaf.operator + when Operators::EQUAL then [leaf.value] + when Operators::IN then Array(leaf.value) + else [] + end + end + + values.empty? ? nil : values + end + + def decode_synthetic_id(value) + # Format: "-". Both are positive integers. + parts = value.to_s.split('-') + return [nil, nil] unless parts.size == 2 + + c_id, t_id = parts.map { |p| Integer(p, 10) rescue nil } + [c_id, t_id] + 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). Forest URL becomes + # /ZendeskComment/-; filter on `id` carries the + # full synthetic value, which we decode in #list. + 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..f3c4e7e7b --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/organization.rb @@ -0,0 +1,115 @@ +module ForestAdminDatasourceZendesk + module Collections + class Organization < BaseCollection + 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 + + def list(caller, filter, projection) + timezone = timezone_for(caller) + ids = extract_id_lookup(filter.condition_tree) + records = if ids + ids.filter_map { |id| datasource.client.find_organization(id) } + else + query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone + ) + sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) + page, per_page = translate_page(filter.page) + datasource.client.search('organization', query: query, + sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) + end + records.map { |o| project(serialize(o), projection) } + end + + 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 + + query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone_for(caller) + ) + count = datasource.client.count('organization', query: [query, filter.search].compact.reject(&:empty?).join(' ')) + [{ 'value' => count, 'group' => {} }] + end + + 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 do |cf| + add_field(cf[:column_name], cf[:schema]) + end + 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 = { + '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'] + } + + org_fields = attrs['organization_fields'] || {} + @custom_fields.each do |cf| + result[cf[:column_name]] = org_fields[cf[:zendesk_key]] + end + + result + 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..2228115a0 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/ticket.rb @@ -0,0 +1,233 @@ +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 + + 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. ' \ + "Got operation=#{aggregation.operation.inspect}, field=#{aggregation.field.inspect}, groups=#{aggregation.groups.inspect}" + end + + count = datasource.client.count('ticket', query: build_query(filter, timezone_for(caller))) + [{ 'value' => count, 'group' => {} }] + 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 + ) + [translated, filter.search].compact.reject(&:empty?).join(' ') + end + + # Embeds requester/assignee/organization (ManyToOne) when their projection + # paths are requested. OneToMany relations (`comments`, etc.) are fetched + # lazily by Forest via separate /relationships requests, so we don't + # eager-load them here. + # Reads FK values from the source Zendesk records (not the projected + # rows, which may have stripped FK columns) and embeds the related + # objects onto the projected rows by index. + def embed_relations(records, rows, projection) + return if projection.nil? + + relation_prefixes = relations_in(projection) + return if relation_prefixes.empty? + + sources = records.map { |t| attrs_of(t) } + + if relation_prefixes.include?('requester') || relation_prefixes.include?('assignee') + 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 relation_prefixes.include?('requester') + row['assignee'] = serialized_user(users[sources[i]['assignee_id']]) if relation_prefixes.include?('assignee') + end + end + + if relation_prefixes.include?('organization') + ids = sources.map { |a| a['organization_id'] }.compact.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 + 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 do |cf| + add_field(cf[:column_name], cf[:schema]) + end + 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 = { + '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'] + } + + cf_values_by_id = Array(attrs['custom_fields']).to_h { |f| [f['id'], f['value']] } + @custom_fields.each do |cf| + result[cf[:column_name]] = cf_values_by_id[cf[:zendesk_id]] + end + + result + 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..27604f668 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/user.rb @@ -0,0 +1,128 @@ +module ForestAdminDatasourceZendesk + module Collections + class User < BaseCollection + 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 + + def list(caller, filter, projection) + timezone = timezone_for(caller) + ids = extract_id_lookup(filter.condition_tree) + records = if ids + ids.filter_map { |id| datasource.client.find_user(id) } + else + query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone + ) + sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) + page, per_page = translate_page(filter.page) + datasource.client.search('user', query: query, sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) + end + records.map { |u| project(serialize(u), projection) } + end + + 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 + + query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone_for(caller) + ) + count = datasource.client.count('user', query: [query, filter.search].compact.reject(&:empty?).join(' ')) + [{ 'value' => count, 'group' => {} }] + end + + 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 do |cf| + add_field(cf[:column_name], cf[:schema]) + end + end + + def define_relations + # Org relation depends on the Organization collection existing in the datasource. + # We declare the relation regardless; if the collection isn't registered, Forest + # will surface a clear error when something tries to traverse it. + 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 = { + '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'] + } + + user_fields = attrs['user_fields'] || {} + @custom_fields.each do |cf| + result[cf[:column_name]] = user_fields[cf[:zendesk_key]] + end + + result + 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..894b9b9ae --- /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..782c19670 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/datasource.rb @@ -0,0 +1,46 @@ +module ForestAdminDatasourceZendesk + class Datasource < ForestAdminDatasourceToolkit::Datasource + attr_reader :client, :configuration + + def initialize(subdomain:, username:, token:) + super() + @configuration = Configuration.new(subdomain: subdomain, username: username, token: token) + @client = Client.new(@configuration) + + 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)) + + register_custom_field_translations(ticket_cf, user_cf, org_cf) + end + + # The translator needs the (Forest column → Zendesk search field) mapping + # to translate filters on custom fields. We hand it the merged set so any + # filter on a custom column resolves to the right search syntax. + def register_custom_field_translations(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 + + ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping = 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..4a311bd1b --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb @@ -0,0 +1,113 @@ +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: when `Datasource#register_custom_field_translations` + # has set `custom_field_mapping`, filters on a custom column are rewritten + # to the Zendesk-side search field (e.g. `custom_360001234` → + # `custom_field_360001234`, or `vip_tier` → `vip_tier` for keyed + # user/org fields). + # + # Timezone handling: callers may pass `timezone:` to `.call`; 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. + class ConditionTreeTranslator + Operators = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Operators + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch + Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf + + class << self + attr_accessor :custom_field_mapping + + def call(condition_tree, timezone: nil) + return '' if condition_tree.nil? + + new(timezone: timezone).translate(condition_tree) + end + end + + self.custom_field_mapping = {} + + def initialize(timezone: nil) + @timezone = timezone || 'UTC' + 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 Array(value).map { |v| "#{field}:#{format_value(v)}" }.join(' ') + when Operators::NOT_IN then Array(value).map { |v| "-#{field}:#{format_value(v)}" }.join(' ') + 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 + + def mapped_field(field) + self.class.custom_field_mapping[field] || field + end + + def format_value(value) + case value + when Time, DateTime + value.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + when Date + # Interpret a bare Date as 00:00 in the caller's timezone, then to UTC. + # If activesupport's TZ table doesn't know the zone, fall back to UTC. + Time.use_zone(@timezone) do + Time.zone.local(value.year, value.month, value.day).utc.strftime('%Y-%m-%dT%H:%M:%SZ') + end + when String + value.match?(/\s/) ? %("#{value}") : value + else + value.to_s + end + rescue ArgumentError + # Unknown timezone identifier — degrade to UTC interpretation. + ForestAdminDatasourceZendesk.logger.warn( + "[forest_admin_datasource_zendesk] unknown timezone '#{@timezone}', falling back to UTC" + ) + value.is_a?(Date) ? value.strftime('%Y-%m-%dT00:00:00Z') : value.to_s + 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..d8351a71c --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/schema/custom_fields_introspector.rb @@ -0,0 +1,115 @@ +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).filter_map do |raw| + next unless raw['active'] + # System ticket fields can't be removed; skip them so we don't + # double-up (e.g., `subject`, `status`, `priority`). + next if key_strategy == :ticket && raw['removable'] == false + + column_type = ZENDESK_TO_COLUMN_TYPE[raw['type']] + next unless column_type + + name, key = column_naming(raw, key_strategy) + schema = build_schema(raw, column_type) + { column_name: name, zendesk_id: raw['id'], zendesk_key: key, schema: schema } + end + 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']).map { |o| o['value'] }.compact + # 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..1d24bf8ea --- /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..bcd46ed35 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/comment_spec.rb @@ -0,0 +1,97 @@ +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 '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..ea93b9ca9 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/organization_spec.rb @@ -0,0 +1,75 @@ +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) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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 { + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) + }.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..b40cde273 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/ticket_spec.rb @@ -0,0 +1,367 @@ +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) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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, ['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 '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(:fetch_user_emails).and_return({}) + allow(client).to receive(:fetch_users_by_ids).and_return(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(:fetch_user_emails).and_return({}) + allow(client).to receive(:fetch_users_by_ids).and_return(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(:search).and_return([ + zendesk_record('id' => 9, 'requester_id' => 999, 'assignee_id' => nil, 'organization_id' => nil) + ]) + allow(client).to receive(:fetch_user_emails).and_return({}) + allow(client).to receive(:fetch_users_by_ids).and_return({}) + + 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(:search).and_return([zendesk_record('id' => 1, 'requester_id' => nil)]) + allow(client).to receive(:fetch_user_emails).and_return({}) + + 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(:search).and_return([plain_hash_ticket]) + allow(client).to receive(:fetch_user_emails).and_return({}) + + 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 { + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'priority')) + }.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: 360001, 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' => 360001, 'value' => 'gold' }, + { 'id' => 999999, 'value' => 'ignored' } + ]) + allow(client).to receive(:search).and_return([ticket]) + allow(client).to receive(:fetch_user_emails).and_return({}) + + 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..563f9d728 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/collections/user_spec.rb @@ -0,0 +1,80 @@ +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) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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 'raises on unsupported aggregations' do + expect { + collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) + }.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..2d84093d1 --- /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.merge(subdomain: nil)) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /subdomain/) + end + + it 'raises a ConfigurationError when subdomain is blank' do + expect { described_class.new(**valid_args.merge(subdomain: ' ')) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /subdomain/) + end + + it 'raises with username when username is missing' do + expect { described_class.new(**valid_args.merge(username: nil)) } + .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /username/) + end + + it 'raises with token when token is missing' do + expect { described_class.new(**valid_args.merge(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..316d07995 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/datasource_spec.rb @@ -0,0 +1,69 @@ +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 'populates the translator custom_field_mapping for ticket custom fields' 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' }) + + described_class.new(**valid_args) + mapping = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping + expect(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' }) + + described_class.new(**valid_args) + mapping = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping + expect(mapping['tier']).to eq('tier') + 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..68acd3323 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/query/condition_tree_translator_spec.rb @@ -0,0 +1,161 @@ +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 + 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 + around do |ex| + previous = described_class.custom_field_mapping + described_class.custom_field_mapping = { 'custom_360001' => 'custom_field_360001' } + ex.run + described_class.custom_field_mapping = previous + end + + it 'rewrites a custom field column name to the Zendesk Search field' do + expect(translate(Leaf.new('custom_360001', 'equal', 'gold'))) + .to eq('custom_field_360001:gold') + end + + it 'leaves non-mapped fields untouched' do + expect(translate(Leaf.new('status', 'equal', 'open'))).to eq('status:open') + 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..9f5297b97 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/forest_admin_datasource_zendesk/schema/custom_fields_introspector_spec.rb @@ -0,0 +1,106 @@ +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..d6b162fb8 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb @@ -0,0 +1,26 @@ +require 'simplecov' +SimpleCov.start do + add_filter '/spec/' + enable_coverage :branch + minimum_coverage 90 +end + +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 From 20e6af07dace35b47334b38482e18fba0425360b Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Mon, 27 Apr 2026 19:08:34 +0200 Subject: [PATCH 2/6] chore(zendesk): satisfy RuboCop - Apply rubocop -A across the package (whitespace, indentation, hash alignment, frozen string literals). - Add packages/forest_admin_datasource_zendesk/.rubocop.yml inheriting the repo root config and: - relaxing Metrics/* limits for the wide-fact-table schema files - exempting spec/** from RSpec stylistic cops we use heavily (StubbedMock, MessageSpies, LeakyConstantDeclaration, ConstantDefinitionInBlock) and from Layout/LineLength - Collapse must_succeed's two identical rescue branches in client.rb. - Move Branch constant out of the `private` scope in comment.rb. Specs still pass: 131 examples, 0 failures, 98.7% line / 90.9% branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.rubocop.yml | 42 ++++++ .../forest_admin_datasource_zendesk.gemspec | 2 +- .../forest_admin_datasource_zendesk/client.rb | 10 +- .../collections/base_collection.rb | 2 +- .../collections/comment.rb | 59 ++++---- .../collections/organization.rb | 59 ++++---- .../collections/ticket.rb | 130 ++++++++++-------- .../collections/user.rb | 66 ++++----- .../configuration.rb | 2 +- .../schema/custom_fields_introspector.rb | 30 ++-- .../client_spec.rb | 26 ++-- .../collections/comment_spec.rb | 19 +-- .../collections/organization_spec.rb | 7 +- .../collections/ticket_spec.rb | 58 ++++---- .../collections/user_spec.rb | 12 +- .../configuration_spec.rb | 8 +- .../query/condition_tree_translator_spec.rb | 12 +- .../schema/custom_fields_introspector_spec.rb | 64 +++++---- 18 files changed, 331 insertions(+), 277 deletions(-) create mode 100644 packages/forest_admin_datasource_zendesk/.rubocop.yml 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/forest_admin_datasource_zendesk.gemspec b/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec index c83dd0176..ad793ba64 100644 --- a/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec +++ b/packages/forest_admin_datasource_zendesk/forest_admin_datasource_zendesk.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |spec| 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'] = 'false' + spec.metadata['rubygems_mfa_required'] = 'true' spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| 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 index 95a644213..5fa4dd1af 100644 --- 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 @@ -113,7 +113,7 @@ def bulk_show_many(resource, ids) ids = Array(ids).compact.uniq return {} if ids.empty? - ids.each_slice(MAX_PER_PAGE).each_with_object({}) do |batch, acc| + 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) @@ -124,12 +124,10 @@ def bulk_show_many(resource, ids) def must_succeed(operation) yield - rescue ZendeskAPI::Error::RecordNotFound => e - # Bubble untouched; these are domain-level "not found" signals callers - # of find_* already handle. For search/count this shouldn't happen, but - # if it does we'd want it visible. - raise APIError, "Zendesk API call failed: #{operation}: #{e.class}: #{e.message}" 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 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 index 9f87c18e5..76d0f5c77 100644 --- 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 @@ -56,7 +56,7 @@ def translate_sort(sort, allow_list) def translate_page(page) return [1, Client::MAX_PER_PAGE] if page.nil? - per_page = page.limit && page.limit.positive? ? [page.limit, Client::MAX_PER_PAGE].min : Client::MAX_PER_PAGE + 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 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 index 3bcc72d81..1d6f29b3a 100644 --- 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 @@ -6,6 +6,7 @@ module Collections # else raises ForestException. class Comment < BaseCollection ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema + Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch def initialize(datasource) super(datasource, 'ZendeskComment') @@ -54,8 +55,6 @@ def list(_caller, filter, projection) private - Branch = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeBranch - # 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) @@ -78,7 +77,7 @@ def decode_synthetic_id(value) parts = value.to_s.split('-') return [nil, nil] unless parts.size == 2 - c_id, t_id = parts.map { |p| Integer(p, 10) rescue nil } + c_id, t_id = parts.map { |p| Integer(p, 10, exception: false) } [c_id, t_id] end @@ -99,53 +98,53 @@ def define_schema # /ZendeskComment/-; filter on `id` carries the # full synthetic value, which we decode in #list. 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)) + 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)) + 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)) + is_read_only: true, is_sortable: false)) add_field('body', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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)) + 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)) + is_read_only: true, is_sortable: false)) add_field('public', ColumnSchema.new(column_type: 'Boolean', filter_operators: [], - is_read_only: true, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('type', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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)) + 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' - )) + 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' - )) + 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'], + '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'] + 'created_at' => attrs['created_at'] } 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 index f3c4e7e7b..eba818e4b 100644 --- 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 @@ -6,7 +6,7 @@ class Organization < BaseCollection ZENDESK_SORTABLE = { 'created_at' => 'created_at', 'updated_at' => 'updated_at', - 'name' => 'name' + 'name' => 'name' }.freeze def initialize(datasource, custom_fields: []) @@ -30,8 +30,8 @@ def list(caller, filter, projection) sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) page, per_page = translate_page(filter.page) datasource.client.search('organization', query: query, - sort_by: sort_by, sort_order: sort_order, - page: page, per_page: per_page) + sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) end records.map { |o| project(serialize(o), projection) } end @@ -45,7 +45,8 @@ def aggregate(caller, filter, aggregation, _limit = nil) query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( filter.condition_tree, timezone: timezone_for(caller) ) - count = datasource.client.count('organization', query: [query, filter.search].compact.reject(&:empty?).join(' ')) + count = datasource.client.count('organization', + query: [query, filter.search].compact.reject(&:empty?).join(' ')) [{ 'value' => count, 'group' => {} }] end @@ -53,23 +54,23 @@ def aggregate(caller, filter, aggregation, _limit = nil) 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)) + 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)) + 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)) + is_read_only: true, is_sortable: false)) add_field('details', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + is_read_only: true, is_sortable: false)) add_field('notes', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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)) + 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)) + 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)) + 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)) + is_read_only: true, is_sortable: true)) @custom_fields.each do |cf| add_field(cf[:column_name], cf[:schema]) @@ -78,29 +79,29 @@ def define_schema def define_relations add_field('users', OneToManySchema.new( - foreign_collection: 'ZendeskUser', - origin_key: 'organization_id', - origin_key_target: 'id' - )) + 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' - )) + foreign_collection: 'ZendeskTicket', + origin_key: 'organization_id', + origin_key_target: 'id' + )) end def serialize(org) attrs = attrs_of(org) result = { - 'id' => attrs['id'], - 'name' => attrs['name'], - 'domain_names' => attrs['domain_names'], - 'details' => attrs['details'], - 'notes' => attrs['notes'], - 'group_id' => attrs['group_id'], + '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'] + 'created_at' => attrs['created_at'], + 'updated_at' => attrs['updated_at'] } org_fields = attrs['organization_fields'] || {} 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 index 2228115a0..cea43289d 100644 --- 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 @@ -9,10 +9,10 @@ class Ticket < BaseCollection ENUM_TYPE = %w[problem incident question task].freeze ZENDESK_SORTABLE = { - 'updated_at' => 'updated_at', - 'created_at' => 'created_at', - 'priority' => 'priority', - 'status' => 'status', + 'updated_at' => 'updated_at', + 'created_at' => 'created_at', + 'priority' => 'priority', + 'status' => 'status', 'ticket_type' => 'ticket_type' }.freeze @@ -37,7 +37,9 @@ 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. ' \ - "Got operation=#{aggregation.operation.inspect}, field=#{aggregation.field.inspect}, groups=#{aggregation.groups.inspect}" + "Got operation=#{aggregation.operation.inspect}, " \ + "field=#{aggregation.field.inspect}, " \ + "groups=#{aggregation.groups.inspect}" end count = datasource.client.count('ticket', query: build_query(filter, timezone_for(caller))) @@ -55,7 +57,7 @@ def fetch_records(filter, timezone) 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) + page: page, per_page: per_page) end def needs_requester_email?(projection) @@ -93,17 +95,23 @@ def embed_relations(records, rows, projection) 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 relation_prefixes.include?('requester') - row['assignee'] = serialized_user(users[sources[i]['assignee_id']]) if relation_prefixes.include?('assignee') + if relation_prefixes.include?('requester') + row['requester'] = + serialized_user(users[sources[i]['requester_id']]) + end + if relation_prefixes.include?('assignee') + row['assignee'] = + serialized_user(users[sources[i]['assignee_id']]) + end end end - if relation_prefixes.include?('organization') - ids = sources.map { |a| a['organization_id'] }.compact.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 + return unless relation_prefixes.include?('organization') + + 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 @@ -140,37 +148,37 @@ def serialized_org(raw) 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)) + 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)) + is_read_only: true, is_sortable: false)) add_field('description', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + is_read_only: true, is_sortable: true)) @custom_fields.each do |cf| add_field(cf[:column_name], cf[:schema]) @@ -179,46 +187,46 @@ def define_schema def define_relations add_field('requester', ManyToOneSchema.new( - foreign_collection: 'ZendeskUser', - foreign_key: 'requester_id', - foreign_key_target: 'id' - )) + 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' - )) + 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' - )) + 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' - )) + foreign_collection: 'ZendeskComment', + origin_key: 'ticket_id', + origin_key_target: 'id' + )) end def serialize(ticket, emails = {}) attrs = attrs_of(ticket) result = { - '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'], + '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'], + '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'] + 'tags' => attrs['tags'], + 'url' => attrs['url'], + 'created_at' => attrs['created_at'], + 'updated_at' => attrs['updated_at'] } cf_values_by_id = Array(attrs['custom_fields']).to_h { |f| [f['id'], f['value']] } 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 index 27604f668..59f37996d 100644 --- 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 @@ -8,7 +8,7 @@ class User < BaseCollection ZENDESK_SORTABLE = { 'created_at' => 'created_at', 'updated_at' => 'updated_at', - 'name' => 'name' + 'name' => 'name' }.freeze def initialize(datasource, custom_fields: []) @@ -54,29 +54,29 @@ def aggregate(caller, filter, aggregation, _limit = nil) 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)) + 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)) + 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)) + 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)) + 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)) + 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)) + is_read_only: true, is_sortable: false)) add_field('locale', ColumnSchema.new(column_type: 'String', filter_operators: [], - is_read_only: true, is_sortable: false)) + 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)) + 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)) + 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)) + 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)) + is_read_only: true, is_sortable: true)) @custom_fields.each do |cf| add_field(cf[:column_name], cf[:schema]) @@ -88,32 +88,32 @@ def define_relations # We declare the relation regardless; if the collection isn't registered, Forest # will surface a clear error when something tries to traverse it. add_field('organization', ManyToOneSchema.new( - foreign_collection: 'ZendeskOrganization', - foreign_key: 'organization_id', - foreign_key_target: 'id' - )) + 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' - )) + foreign_collection: 'ZendeskTicket', + origin_key: 'requester_id', + origin_key_target: 'id' + )) end def serialize(user) attrs = attrs_of(user) result = { - 'id' => attrs['id'], - 'email' => attrs['email'], - 'name' => attrs['name'], - 'role' => attrs['role'], - 'phone' => attrs['phone'], + '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'] + 'time_zone' => attrs['time_zone'], + 'locale' => attrs['locale'], + 'verified' => attrs['verified'], + 'suspended' => attrs['suspended'], + 'created_at' => attrs['created_at'], + 'updated_at' => attrs['updated_at'] } user_fields = attrs['user_fields'] || {} 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 index 894b9b9ae..f3c26dc8e 100644 --- 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 @@ -23,7 +23,7 @@ def validate! return if missing.empty? raise ConfigurationError, - "ForestAdminDatasourceZendesk missing required config: #{missing.join(', ')}" + "ForestAdminDatasourceZendesk missing required config: #{missing.join(", ")}" end def blank?(value) 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 index d8351a71c..92d32090c 100644 --- 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 @@ -20,18 +20,18 @@ class CustomFieldsIntrospector Operators::PRESENT, Operators::BLANK].freeze ZENDESK_TO_COLUMN_TYPE = { - 'text' => 'String', - 'textarea' => 'String', - 'regexp' => 'String', + 'text' => 'String', + 'textarea' => 'String', + 'regexp' => 'String', 'partialcreditcard' => 'String', - 'integer' => 'Number', - 'decimal' => 'Number', - 'date' => 'Dateonly', - 'checkbox' => 'Boolean', - 'dropdown' => 'Enum', - 'tagger' => 'Enum', - 'multiselect' => 'Json', - 'lookup' => 'Number' + 'integer' => 'Number', + 'decimal' => 'Number', + 'date' => 'Dateonly', + 'checkbox' => 'Boolean', + 'dropdown' => 'Enum', + 'tagger' => 'Enum', + 'multiselect' => 'Json', + 'lookup' => 'Number' }.freeze def initialize(client) @@ -72,9 +72,9 @@ def column_naming(raw, strategy) case strategy when :ticket # No reliable key on ticket_fields; use the id. - ["custom_#{raw['id']}", nil] + ["custom_#{raw["id"]}", nil] when :user_or_org - key = raw['key'] || "custom_#{raw['id']}" + key = raw['key'] || "custom_#{raw["id"]}" [key, key] end end @@ -88,7 +88,7 @@ def build_schema(raw, column_type) } if column_type == 'Enum' - opts[:enum_values] = Array(raw['custom_field_options']).map { |o| o['value'] }.compact + 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? @@ -103,7 +103,7 @@ def build_schema(raw, column_type) def filter_operators_for(column_type) case column_type - when 'Number' then NUMBER_OPS + when 'Number' then NUMBER_OPS when 'Dateonly' then DATE_OPS when 'Boolean' then [Operators::EQUAL, Operators::NOT_EQUAL] when 'Json' then [] 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 index 1d24bf8ea..ca8bebe54 100644 --- 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 @@ -15,8 +15,8 @@ def empty_search_response 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) + .with(query: hash_including('query' => 'type:ticket')) + .to_return(empty_search_response) client.search('ticket', query: '') expect(stub).to have_been_requested @@ -24,8 +24,8 @@ def empty_search_response 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) + .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 @@ -33,8 +33,8 @@ def empty_search_response 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) + .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 @@ -42,8 +42,8 @@ def empty_search_response 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) + .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 @@ -62,7 +62,7 @@ def empty_search_response 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') + .to_return(status: 500, body: 'boom') expect { client.count('ticket', query: '') } .to raise_error(ForestAdminDatasourceZendesk::APIError, /count\(ticket\)/) @@ -70,7 +70,7 @@ def empty_search_response 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' }) + .to_return(status: 200, body: '{}', headers: { 'Content-Type' => 'application/json' }) expect(client.count('ticket', query: '')).to eq(0) end @@ -175,7 +175,7 @@ def empty_search_response 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') + .to_return(status: 500, body: 'boom') expect(client.fetch_user_emails([1])).to eq({}) end end @@ -198,7 +198,7 @@ def empty_search_response it 'returns {} on error' do stub_request(:get, "#{base}/users/show_many").with(query: hash_including({})) - .to_return(status: 500, body: 'boom') + .to_return(status: 500, body: 'boom') expect(client.fetch_users_by_ids([1])).to eq({}) end end @@ -223,7 +223,7 @@ def empty_search_response 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') + .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({}) 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 index bcd46ed35..6c3fe9be8 100644 --- 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 @@ -29,9 +29,9 @@ 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' } - ]) + { '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) @@ -50,7 +50,7 @@ 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) + Filter.new(condition_tree: Leaf.new('public', 'equal', true)), nil) expect(result).to eq([]) end @@ -67,9 +67,9 @@ 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' } - ]) + { '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) @@ -86,8 +86,9 @@ it 'flattens via.channel into via_channel' do expect(client).to receive(:fetch_ticket_comments).with(1).and_return([ - { 'id' => 99, 'via' => { 'channel' => 'web' } } - ]) + { 'id' => 99, + 'via' => { 'channel' => 'web' } } + ]) filter = Filter.new(condition_tree: Leaf.new('ticket_id', 'equal', 1)) result = collection.list(nil, filter, nil).first 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 index ea93b9ca9..ac9a8f940 100644 --- 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 @@ -50,9 +50,9 @@ def zendesk_org(attrs) end it 'raises on unsupported aggregations' do - expect { + expect do collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) - }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) end end @@ -60,7 +60,8 @@ def zendesk_org(attrs) 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) }] + column_type: 'String', filter_operators: [], is_read_only: true + ) }] end let(:collection) { described_class.new(datasource, custom_fields: cf) } 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 index b40cde273..078b1ad49 100644 --- 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 @@ -53,7 +53,7 @@ def zendesk_record(attrs) expect(client).not_to receive(:search) filter = Filter.new(condition_tree: Leaf.new('id', 'equal', 215)) - result = collection.list(nil, filter, ['id', 'subject', 'requester_email']) + 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') @@ -160,10 +160,10 @@ def zendesk_record(attrs) 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) - ]) + 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 @@ -223,8 +223,7 @@ def zendesk_record(attrs) end it 'embeds only requester (no assignee) when only requester:* is in projection' do - allow(client).to receive(:fetch_user_emails).and_return({}) - allow(client).to receive(:fetch_users_by_ids).and_return(10 => { 'id' => 10 }) + 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') @@ -232,8 +231,7 @@ def zendesk_record(attrs) end it 'fetches users but only embeds assignee when only assignee:* is requested' do - allow(client).to receive(:fetch_user_emails).and_return({}) - allow(client).to receive(:fetch_users_by_ids).and_return(11 => { 'id' => 11 }) + 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') @@ -241,11 +239,10 @@ def zendesk_record(attrs) end it 'leaves the relation hash nil when the foreign id is not resolvable' do - allow(client).to receive(:search).and_return([ - zendesk_record('id' => 9, 'requester_id' => 999, 'assignee_id' => nil, 'organization_id' => nil) - ]) - allow(client).to receive(:fetch_user_emails).and_return({}) - allow(client).to receive(:fetch_users_by_ids).and_return({}) + 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 @@ -254,8 +251,8 @@ def zendesk_record(attrs) describe 'projection edge cases' do it 'returns the full record when projection is nil' do - allow(client).to receive(:search).and_return([zendesk_record('id' => 1, 'requester_id' => nil)]) - allow(client).to receive(:fetch_user_emails).and_return({}) + 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') @@ -263,8 +260,7 @@ def zendesk_record(attrs) 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(:search).and_return([plain_hash_ticket]) - allow(client).to receive(:fetch_user_emails).and_return({}) + 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') @@ -306,8 +302,8 @@ def zendesk_record(attrs) 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: {}) + 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') [] @@ -324,27 +320,26 @@ def zendesk_record(attrs) 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') - ) + 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 { + expect do collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'priority')) - }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + 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) + filter_operators: [], is_read_only: true) end let(:custom_fields) do - [{ column_name: 'custom_360001', zendesk_id: 360001, zendesk_key: nil, schema: cf_schema }] + [{ 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) } @@ -354,11 +349,10 @@ def zendesk_record(attrs) it 'serializes the custom field value from ticket.custom_fields array' do ticket = zendesk_record('id' => 1, 'requester_id' => nil, 'custom_fields' => [ - { 'id' => 360001, 'value' => 'gold' }, - { 'id' => 999999, 'value' => 'ignored' } - ]) - allow(client).to receive(:search).and_return([ticket]) - allow(client).to receive(:fetch_user_emails).and_return({}) + { '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') 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 index 563f9d728..6efcbb7b0 100644 --- 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 @@ -47,17 +47,16 @@ def zendesk_user(attrs) 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') - ) + Filter.new(condition_tree: Leaf.new('role', 'equal', 'admin')), + Aggregation.new(operation: 'Count')) expect(result).to eq([{ 'value' => 3, 'group' => {} }]) end it 'raises on unsupported aggregations' do - expect { + expect do collection.aggregate(nil, Filter.new, Aggregation.new(operation: 'Sum', field: 'id')) - }.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException) end end @@ -65,7 +64,8 @@ def zendesk_user(attrs) 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) }] + column_type: 'String', filter_operators: [], is_read_only: true + ) }] end let(:collection) { described_class.new(datasource, custom_fields: cf) } 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 index 2d84093d1..6ce04e613 100644 --- 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 @@ -13,22 +13,22 @@ end it 'raises a ConfigurationError when subdomain is nil' do - expect { described_class.new(**valid_args.merge(subdomain: nil)) } + 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.merge(subdomain: ' ')) } + 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.merge(username: nil)) } + 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.merge(token: '')) } + expect { described_class.new(**valid_args, token: '') } .to raise_error(ForestAdminDatasourceZendesk::ConfigurationError, /token/) 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 index 68acd3323..3684089c1 100644 --- 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 @@ -137,17 +137,17 @@ def translate(node) 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') - ]) + 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') - ]) + Leaf.new('status', 'equal', 'open'), + Leaf.new('status', 'equal', 'pending') + ]) expect { translate(branch) } .to raise_error(ForestAdminDatasourceZendesk::UnsupportedOperatorError, /OR/i) 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 index 9f5297b97..441043d13 100644 --- 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 @@ -5,11 +5,15 @@ 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 } - ]) + { '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] } @@ -23,11 +27,11 @@ 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' } - ] } - ]) + { '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') @@ -36,8 +40,9 @@ 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 } - ]) + { 'id' => 4, 'type' => 'checkbox', 'active' => true, + 'removable' => true } + ]) cf = introspector.ticket_custom_fields.first expect(cf[:schema].column_type).to eq('Boolean') @@ -46,9 +51,9 @@ 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' => [] } - ]) + { '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') @@ -56,22 +61,25 @@ it 'skips inactive fields' do allow(client).to receive(:fetch_ticket_fields).and_return([ - { 'id' => 7, 'type' => 'text', 'active' => false, 'removable' => true } - ]) + { '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 } - ]) + { '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 } - ]) + { 'id' => 9, 'type' => 'mystery', 'active' => true, + 'removable' => true } + ]) expect(introspector.ticket_custom_fields).to be_empty end end @@ -79,8 +87,9 @@ 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 } - ]) + { 'id' => 100, 'type' => 'text', 'key' => 'tier', + 'active' => true } + ]) result = introspector.user_custom_fields.first expect(result[:column_name]).to eq('tier') @@ -89,8 +98,8 @@ 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 } - ]) + { 'id' => 101, 'type' => 'text', 'active' => true } + ]) expect(introspector.user_custom_fields.first[:column_name]).to eq('custom_101') end end @@ -98,8 +107,9 @@ 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 } - ]) + { 'id' => 200, 'type' => 'text', + 'key' => 'plan', 'active' => true } + ]) expect(introspector.organization_custom_fields.first[:column_name]).to eq('plan') end end From cabf1669b30274eecf59105b3923a2dfdfe548e7 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Tue, 28 Apr 2026 08:58:44 +0200 Subject: [PATCH 3/6] refactor(zendesk): address qltysh complexity / duplication review Resolves the 12 review comments left by qltysh on the previous push. Duplication - New `Collections::Searchable` mixin holds the list/find/aggregate flow that User and Organization both used. Each collection now only declares its zendesk_resource, sortable_fields, find_one and serialize, eliminating the ~16 lines of similar code qlty flagged. Complexity - BaseCollection now owns the (caller, filter, aggregation, _limit) contract method. Subclasses override `aggregate_count(caller, filter)` with a 2-arg helper. Ticket/User/Organization no longer carry their own copies of the same validation+raise+count boilerplate. - Ticket#embed_relations split into `embed_users` and `embed_organizations` (cyclomatic complexity 11 -> ~3 each). - Comment#list extracts `resolve_scope(filter)` and `fetch_comments` (complexity 9 -> 3). #extract_field_lookup pulled out `values_from_leaf` (complexity 5 -> 2). - ConditionTreeTranslator#format_value split into format_date and format_string (complexity 7 -> 3). Date / TZ degradation logic now lives inside format_date with a tighter rescue scope. - CustomFieldsIntrospector#introspect split into `usable_field?` and `build_entry` predicates (complexity 10 -> 3). - BaseCollection#translate_sort extracts `sort_field_and_direction` for the Sort::Clause vs hash branching. Parameter counts - Client#search collapsed five named kwargs into a single `**opts` hash + `build_search_params` helper. Callers (the Searchable mixin and Ticket#fetch_records) keep the same kwarg call sites. - aggregate's 4-param signature only remains on BaseCollection where it's mandated by ForestAdminDatasourceToolkit::Components::Contracts:: CollectionContract; subclasses no longer carry it. 131 specs still pass, 98.4% line / 91.4% branch coverage. RuboCop clean across the package. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../forest_admin_datasource_zendesk/client.rb | 26 +-- .../collections/base_collection.rb | 34 +++- .../collections/comment.rb | 117 +++++++------- .../collections/organization.rb | 98 ++++-------- .../collections/searchable.rb | 57 +++++++ .../collections/ticket.rb | 151 ++++++++---------- .../collections/user.rb | 123 ++++++-------- .../query/condition_tree_translator.rb | 32 ++-- .../schema/custom_fields_introspector.rb | 33 ++-- 9 files changed, 345 insertions(+), 326 deletions(-) create mode 100644 packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/searchable.rb 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 index 5fa4dd1af..529b1a64e 100644 --- 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 @@ -18,15 +18,12 @@ def initialize(configuration) # ---------- Search (critical) ---------- - def search(type, query:, sort_by: nil, sort_order: nil, page: 1, per_page: MAX_PER_PAGE) - params = { - query: compose_query(type, query), - per_page: [per_page, MAX_PER_PAGE].min, - page: page - } - params[:sort_by] = sort_by if sort_by - params[:sort_order] = sort_order if sort_order - + # `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 @@ -144,6 +141,17 @@ 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 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 index 76d0f5c77..424ffa6ba 100644 --- 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 @@ -11,8 +11,24 @@ class BaseCollection < ForestAdminDatasourceToolkit::Collection 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). @@ -43,9 +59,7 @@ def project(record, projection) def translate_sort(sort, allow_list) return [nil, nil] if sort.nil? || sort.empty? - first = sort.first - field = first.respond_to?(:field) ? first.field : first[:field] || first['field'] - ascending = first.respond_to?(:ascending) ? first.ascending : (first[:ascending] || first['ascending']) + field, ascending = sort_field_and_direction(sort.first) zd_field = allow_list[field.to_s] return [nil, nil] unless zd_field @@ -71,6 +85,20 @@ def timezone_for(caller) 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. + def sort_field_and_direction(entry) + if entry.respond_to?(:field) + [entry.field, entry.ascending] + else + [entry[:field] || entry['field'], entry[:ascending] || entry['ascending']] + end + 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 index 1d6f29b3a..eef2bf6cb 100644 --- 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 @@ -1,9 +1,11 @@ module ForestAdminDatasourceZendesk module Collections # Comments are *always* fetched in the context of a parent ticket - # (Zendesk: GET /tickets/{id}/comments). The collection only supports - # filters of the form `ticket_id = N` or `ticket_id IN [...]`. Anything - # else raises ForestException. + # (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 @@ -15,6 +17,26 @@ def initialize(datasource) 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) synthetic_ids = extract_field_lookup(filter.condition_tree, 'id') ticket_ids = extract_field_lookup(filter.condition_tree, 'ticket_id') || [] comment_ids = [] @@ -22,54 +44,36 @@ def list(_caller, filter, projection) Array(synthetic_ids).each do |sid| c_id, t_id = decode_synthetic_id(sid) comment_ids << c_id if c_id - ticket_ids << t_id if t_id + ticket_ids << t_id if t_id end ticket_ids.uniq! - comment_ids = comment_ids.empty? ? nil : comment_ids.uniq - - # Top-level browse with no ticket scope -> return []. Zendesk has no - # /comments listing endpoint; legitimate access (Ticket -> comments - # relation, show route via synthetic id) always carries a ticket_id. - 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 + [ticket_ids, comment_ids.empty? ? nil : comment_ids.uniq] + end - records = ticket_ids.flat_map do |ticket_id| + 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 - - records.map { |c| project(serialize(c), projection) } end - # Counts are deactivated for comments — Zendesk doesn't expose a count - # endpoint for ticket comments, and the list endpoint already returns - # everything in a single response. - - private - # 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 } - return nil if leaves.empty? + values = leaves.flat_map { |l| values_from_leaf(l) } + values.empty? ? nil : values + end - values = leaves.flat_map do |leaf| - case leaf.operator - when Operators::EQUAL then [leaf.value] - when Operators::IN then Array(leaf.value) - else [] - end + def values_from_leaf(leaf) + case leaf.operator + when Operators::EQUAL then [leaf.value] + when Operators::IN then Array(leaf.value) + else [] end - - values.empty? ? nil : values end def decode_synthetic_id(value) @@ -77,8 +81,7 @@ def decode_synthetic_id(value) parts = value.to_s.split('-') return [nil, nil] unless parts.size == 2 - c_id, t_id = parts.map { |p| Integer(p, 10, exception: false) } - [c_id, t_id] + parts.map { |p| Integer(p, 10, exception: false) } end def collect_leaves(node) @@ -90,29 +93,27 @@ def collect_leaves(node) 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). Forest URL becomes - # /ZendeskComment/-; filter on `id` carries the - # full synthetic value, which we decode in #list. - 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)) + # 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('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: [], @@ -143,7 +144,7 @@ def serialize(comment) 'plain_body' => attrs['plain_body'] || attrs['body'], 'public' => attrs['public'], 'type' => attrs['type'], - 'via_channel' => (attrs.dig('via', 'channel') || attrs.dig(:via, :channel)), + 'via_channel' => attrs.dig('via', 'channel') || attrs.dig(:via, :channel), 'created_at' => attrs['created_at'] } 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 index eba818e4b..867f479a4 100644 --- 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 @@ -1,6 +1,8 @@ module ForestAdminDatasourceZendesk module Collections class Organization < BaseCollection + include Searchable + OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema ZENDESK_SORTABLE = { @@ -18,63 +20,35 @@ def initialize(datasource, custom_fields: []) enable_count end - def list(caller, filter, projection) - timezone = timezone_for(caller) - ids = extract_id_lookup(filter.condition_tree) - records = if ids - ids.filter_map { |id| datasource.client.find_organization(id) } - else - query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone - ) - sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) - page, per_page = translate_page(filter.page) - datasource.client.search('organization', query: query, - sort_by: sort_by, sort_order: sort_order, - page: page, per_page: per_page) - end - records.map { |o| project(serialize(o), projection) } - end - - 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 + protected - query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone_for(caller) - ) - count = datasource.client.count('organization', - query: [query, filter.search].compact.reject(&:empty?).join(' ')) - [{ 'value' => count, 'group' => {} }] - end + 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, + 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)) + 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 do |cf| - add_field(cf[:column_name], cf[:schema]) - end + @custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) } end def define_relations @@ -92,25 +66,21 @@ def define_relations def serialize(org) attrs = attrs_of(org) - result = { - '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'] - } - + result = base_attributes(attrs) org_fields = attrs['organization_fields'] || {} - @custom_fields.each do |cf| - result[cf[:column_name]] = org_fields[cf[:zendesk_key]] - end - + @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..cd62286d5 --- /dev/null +++ b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/collections/searchable.rb @@ -0,0 +1,57 @@ +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) + query = compose_count_query(caller, filter) + datasource.client.count(zendesk_resource, query: query) + 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) + query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone_for(caller) + ) + sort_by, sort_order = translate_sort(filter.sort, sortable_fields) + page, per_page = translate_page(filter.page) + + datasource.client.search(zendesk_resource, + query: query, sort_by: sort_by, sort_order: sort_order, + page: page, per_page: per_page) + end + + def compose_count_query(caller, filter) + translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( + filter.condition_tree, timezone: timezone_for(caller) + ) + [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 index cea43289d..471dcd61d 100644 --- 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 @@ -33,17 +33,10 @@ def list(caller, filter, projection) rows end - 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. ' \ - "Got operation=#{aggregation.operation.inspect}, " \ - "field=#{aggregation.field.inspect}, " \ - "groups=#{aggregation.groups.inspect}" - end + protected - count = datasource.client.count('ticket', query: build_query(filter, timezone_for(caller))) - [{ 'value' => count, 'group' => {} }] + def aggregate_count(caller, filter) + datasource.client.count('ticket', query: build_query(filter, timezone_for(caller))) end private @@ -77,37 +70,30 @@ def build_query(filter, timezone) end # Embeds requester/assignee/organization (ManyToOne) when their projection - # paths are requested. OneToMany relations (`comments`, etc.) are fetched - # lazily by Forest via separate /relationships requests, so we don't - # eager-load them here. - # Reads FK values from the source Zendesk records (not the projected - # rows, which may have stripped FK columns) and embeds the related - # objects onto the projected rows by index. + # 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? - relation_prefixes = relations_in(projection) - return if relation_prefixes.empty? + 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 - if relation_prefixes.include?('requester') || relation_prefixes.include?('assignee') - 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| - if relation_prefixes.include?('requester') - row['requester'] = - serialized_user(users[sources[i]['requester_id']]) - end - if relation_prefixes.include?('assignee') - row['assignee'] = - serialized_user(users[sources[i]['assignee_id']]) - end - 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 - return unless relation_prefixes.include?('organization') - + 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| @@ -147,42 +133,40 @@ def serialized_org(raw) 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('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('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 do |cf| - add_field(cf[:column_name], cf[:schema]) - end + 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 @@ -210,31 +194,24 @@ def define_relations def serialize(ticket, emails = {}) attrs = attrs_of(ticket) - result = { - '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'], + 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'] + 'tags' => attrs['tags'], 'url' => attrs['url'], + 'created_at' => attrs['created_at'], 'updated_at' => attrs['updated_at'] } - - cf_values_by_id = Array(attrs['custom_fields']).to_h { |f| [f['id'], f['value']] } - @custom_fields.each do |cf| - result[cf[:column_name]] = cf_values_by_id[cf[:zendesk_id]] - end - - result 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 index 59f37996d..cf1fc3c02 100644 --- 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 @@ -1,9 +1,11 @@ module ForestAdminDatasourceZendesk module Collections class User < BaseCollection - ManyToOneSchema = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema - OneToManySchema = ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema - ENUM_ROLE = %w[end-user agent admin].freeze + 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', @@ -20,73 +22,44 @@ def initialize(datasource, custom_fields: []) enable_count end - def list(caller, filter, projection) - timezone = timezone_for(caller) - ids = extract_id_lookup(filter.condition_tree) - records = if ids - ids.filter_map { |id| datasource.client.find_user(id) } - else - query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone - ) - sort_by, sort_order = translate_sort(filter.sort, ZENDESK_SORTABLE) - page, per_page = translate_page(filter.page) - datasource.client.search('user', query: query, sort_by: sort_by, sort_order: sort_order, - page: page, per_page: per_page) - end - records.map { |u| project(serialize(u), projection) } - end + protected - 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 - - query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone_for(caller) - ) - count = datasource.client.count('user', query: [query, filter.search].compact.reject(&:empty?).join(' ')) - [{ 'value' => count, 'group' => {} }] - end + 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('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)) + 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 do |cf| - add_field(cf[:column_name], cf[:schema]) - end + @custom_fields.each { |cf| add_field(cf[:column_name], cf[:schema]) } end def define_relations - # Org relation depends on the Organization collection existing in the datasource. - # We declare the relation regardless; if the collection isn't registered, Forest - # will surface a clear error when something tries to traverse it. add_field('organization', ManyToOneSchema.new( foreign_collection: 'ZendeskOrganization', foreign_key: 'organization_id', @@ -101,28 +74,22 @@ def define_relations def serialize(user) attrs = attrs_of(user) - result = { - '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'] - } - + result = base_attributes(attrs) user_fields = attrs['user_fields'] || {} - @custom_fields.each do |cf| - result[cf[:column_name]] = user_fields[cf[:zendesk_key]] - end - + @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/query/condition_tree_translator.rb b/packages/forest_admin_datasource_zendesk/lib/forest_admin_datasource_zendesk/query/condition_tree_translator.rb index 4a311bd1b..e07cf65fa 100644 --- 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 @@ -88,25 +88,29 @@ def mapped_field(field) def format_value(value) case value - when Time, DateTime - value.utc.strftime('%Y-%m-%dT%H:%M:%SZ') - when Date - # Interpret a bare Date as 00:00 in the caller's timezone, then to UTC. - # If activesupport's TZ table doesn't know the zone, fall back to UTC. - Time.use_zone(@timezone) do - Time.zone.local(value.year, value.month, value.day).utc.strftime('%Y-%m-%dT%H:%M:%SZ') - end - when String - value.match?(/\s/) ? %("#{value}") : value - else - value.to_s + 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 + + # 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 - # Unknown timezone identifier — degrade to UTC interpretation. ForestAdminDatasourceZendesk.logger.warn( "[forest_admin_datasource_zendesk] unknown timezone '#{@timezone}', falling back to UTC" ) - value.is_a?(Date) ? value.strftime('%Y-%m-%dT00:00:00Z') : value.to_s + value.strftime('%Y-%m-%dT00:00:00Z') + end + + def format_string(value) + value.match?(/\s/) ? %("#{value}") : value 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 index 92d32090c..1b0b92a34 100644 --- 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 @@ -53,19 +53,26 @@ def organization_custom_fields private def introspect(raw_fields, key_strategy:) - Array(raw_fields).filter_map do |raw| - next unless raw['active'] - # System ticket fields can't be removed; skip them so we don't - # double-up (e.g., `subject`, `status`, `priority`). - next if key_strategy == :ticket && raw['removable'] == false - - column_type = ZENDESK_TO_COLUMN_TYPE[raw['type']] - next unless column_type - - name, key = column_naming(raw, key_strategy) - schema = build_schema(raw, column_type) - { column_name: name, zendesk_id: raw['id'], zendesk_key: key, schema: schema } - end + 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) From a6b56f0c534b1844d5ac1560e3ad2179d5f0966d Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Tue, 28 Apr 2026 09:21:02 +0200 Subject: [PATCH 4/6] fix(zendesk): address macroscope review (3 bugs) + qlty resolve_scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs caught by macroscopeapp on the previous push: 1. (high) Searchable#search_records dropped filter.search from the query, while compose_count_query included it. Result: list and count returned different totals when the user typed in the search box. Both paths now share `compose_full_query`, which mirrors the logic the Ticket collection has used all along. 2. (high) sort_field_and_direction used `entry[:ascending] || entry['ascending']`, which silently flips a descending sort to ascending when both keys exist with different values, and returns nil instead of false when only the symbol key is set. Switched to `key?` so explicit `ascending: false` round-trips correctly. 3. (low) format_value(nil) emitted `field:` clauses, which Zendesk's search treats as a presence check — semantically wrong for an EQUAL/NOT_EQUAL filter. Now raises UnsupportedOperatorError with a message pointing the caller at PRESENT/BLANK. Plus qlty: Comment#resolve_scope dropped to complexity ~3 by extracting `decoded_synthetic_pairs`. Regression tests added for all three bugs. 135 specs, 0 failures, 98.6% line / 90.0% branch. RuboCop clean. Note: BaseCollection#aggregate's 4-param signature still trips qlty's `function-parameters` cop. That signature is structurally required by ForestAdminDatasourceToolkit::Components::Contracts::CollectionContract; subclasses already override the lower-arity `aggregate_count(caller, filter)` so the 4-arg form lives in exactly one place. No fix is possible without breaking the toolkit contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/base_collection.rb | 15 +++++++---- .../collections/comment.rb | 20 +++++++-------- .../collections/searchable.rb | 16 ++++++------ .../query/condition_tree_translator.rb | 10 ++++++++ .../collections/ticket_spec.rb | 14 +++++++++++ .../collections/user_spec.rb | 25 +++++++++++++++++++ .../query/condition_tree_translator_spec.rb | 5 ++++ 7 files changed, 82 insertions(+), 23 deletions(-) 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 index 424ffa6ba..fc9e176d9 100644 --- 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 @@ -92,12 +92,17 @@ def timezone_for(caller) # `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) - if entry.respond_to?(:field) - [entry.field, entry.ascending] - else - [entry[:field] || entry['field'], entry[:ascending] || entry['ascending']] - end + 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 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 index eef2bf6cb..60e672efc 100644 --- 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 @@ -37,20 +37,18 @@ def list(_caller, filter, projection) # (`id = "-"`). `comment_ids` may be nil # (meaning "no narrowing — return all comments for the ticket"). def resolve_scope(filter) - synthetic_ids = extract_field_lookup(filter.condition_tree, 'id') - ticket_ids = extract_field_lookup(filter.condition_tree, 'ticket_id') || [] - comment_ids = [] - - Array(synthetic_ids).each do |sid| - c_id, t_id = decode_synthetic_id(sid) - comment_ids << c_id if c_id - ticket_ids << t_id if t_id - end - - ticket_ids.uniq! + 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 + def decoded_synthetic_pairs(filter) + Array(extract_field_lookup(filter.condition_tree, 'id')) + .map { |sid| decode_synthetic_id(sid) } + 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| 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 index cd62286d5..008867f78 100644 --- 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 @@ -20,8 +20,7 @@ def list(caller, filter, projection) protected def aggregate_count(caller, filter) - query = compose_count_query(caller, filter) - datasource.client.count(zendesk_resource, query: query) + datasource.client.count(zendesk_resource, query: compose_full_query(caller, filter)) end private @@ -35,18 +34,21 @@ def find_records_by_id(filter) end def search_records(caller, filter) - query = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone_for(caller) - ) sort_by, sort_order = translate_sort(filter.sort, sortable_fields) page, per_page = translate_page(filter.page) datasource.client.search(zendesk_resource, - query: query, sort_by: sort_by, sort_order: sort_order, + query: compose_full_query(caller, filter), + sort_by: sort_by, sort_order: sort_order, page: page, per_page: per_page) end - def compose_count_query(caller, filter) + # 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) ) 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 index e07cf65fa..9a7305ada 100644 --- 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 @@ -88,6 +88,7 @@ def mapped_field(field) 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) @@ -95,6 +96,15 @@ def format_value(value) 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. 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 index 078b1ad49..a8dc6fb3f 100644 --- 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 @@ -124,6 +124,20 @@ def zendesk_record(attrs) 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 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 index 6efcbb7b0..663956cf9 100644 --- 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 @@ -53,6 +53,31 @@ def zendesk_user(attrs) 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')) 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 index 3684089c1..5ec4f9fa3 100644 --- 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 @@ -87,6 +87,11 @@ def translate(node) 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 end describe 'timezone handling' do From 12412f703258fac3598653d7072a6e7557e540b5 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Tue, 28 Apr 2026 09:50:15 +0200 Subject: [PATCH 5/6] fix(zendesk): address second macroscope review (4 bugs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenancy: ConditionTreeTranslator no longer keeps the custom-field mapping on class-level state. The mapping now lives on the Datasource instance (`Datasource#custom_field_mapping`), and is threaded through each translator call as `custom_fields:`. Two datasources with different mappings used to step on each other; they're now isolated. Translator robustness: - `format_string` now backslash-escapes internal double quotes when it needs to wrap in quotes. `subject = 'test "with" quotes'` previously emitted a malformed query. - `IN []` and `NOT_IN []` previously produced an empty string that the branch translator silently dropped, turning "match nothing" into "match everything". They now raise UnsupportedOperatorError. Comment#decoded_synthetic_pairs now drops pairs where either half is nil. Previously, `id = "abc-456"` (invalid comment_id, valid ticket_id) contributed `456` to the ticket scope and silently fetched every comment for that ticket — wrong, since the row the user clicked on doesn't exist. Regression tests added for all four bugs. 144 specs, 0 failures, 98.6% line / 90.4% branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../collections/comment.rb | 5 ++ .../collections/searchable.rb | 3 +- .../collections/ticket.rb | 2 +- .../datasource.rb | 19 +++--- .../query/condition_tree_translator.rb | 60 ++++++++++++------- .../collections/comment_spec.rb | 16 +++++ .../collections/organization_spec.rb | 7 ++- .../collections/ticket_spec.rb | 7 ++- .../collections/user_spec.rb | 7 ++- .../datasource_spec.rb | 34 ++++++++--- .../query/condition_tree_translator_spec.rb | 57 +++++++++++++++--- 11 files changed, 162 insertions(+), 55 deletions(-) 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 index 60e672efc..43ba09c8d 100644 --- 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 @@ -44,9 +44,14 @@ def resolve_scope(filter) [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) 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 index 008867f78..364927c50 100644 --- 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 @@ -50,7 +50,8 @@ def search_records(caller, filter) # this builder. def compose_full_query(caller, filter) translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone_for(caller) + filter.condition_tree, timezone: timezone_for(caller), + custom_fields: datasource.custom_field_mapping ) [translated, filter.search].compact.reject(&:empty?).join(' ') 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 index 471dcd61d..799eceb7d 100644 --- 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 @@ -64,7 +64,7 @@ def bulk_fetch_emails(records) def build_query(filter, timezone) translated = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.call( - filter.condition_tree, timezone: timezone + filter.condition_tree, timezone: timezone, custom_fields: datasource.custom_field_mapping ) [translated, filter.search].compact.reject(&:empty?).join(' ') 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 index 782c19670..4f961c8eb 100644 --- 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 @@ -1,11 +1,12 @@ module ForestAdminDatasourceZendesk class Datasource < ForestAdminDatasourceToolkit::Datasource - attr_reader :client, :configuration + 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 @@ -24,23 +25,23 @@ def register_collections add_collection(Collections::Organization.new(self, custom_fields: org_cf)) add_collection(Collections::Comment.new(self)) - register_custom_field_translations(ticket_cf, user_cf, org_cf) + @custom_field_mapping = build_custom_field_mapping(ticket_cf, user_cf, org_cf) end - # The translator needs the (Forest column → Zendesk search field) mapping - # to translate filters on custom fields. We hand it the merged set so any - # filter on a custom column resolves to the right search syntax. - def register_custom_field_translations(ticket_cf, user_cf, org_cf) + # 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 / 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 - - ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping = mapping + 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 index 9a7305ada..ecf889b9a 100644 --- 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 @@ -10,35 +10,30 @@ module Query # Unsupported operators raise UnsupportedOperatorError so failures are # loud, not silent wrong results. # - # Custom-field translation: when `Datasource#register_custom_field_translations` - # has set `custom_field_mapping`, filters on a custom column are rewritten - # to the Zendesk-side search field (e.g. `custom_360001234` → - # `custom_field_360001234`, or `vip_tier` → `vip_tier` for keyed - # user/org fields). + # 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 may pass `timezone:` to `.call`; Date values - # are interpreted as start-of-day in that TZ, then converted to UTC. + # 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. + # 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 - class << self - attr_accessor :custom_field_mapping + def self.call(condition_tree, timezone: nil, custom_fields: {}) + return '' if condition_tree.nil? - def call(condition_tree, timezone: nil) - return '' if condition_tree.nil? - - new(timezone: timezone).translate(condition_tree) - end + new(timezone: timezone, custom_fields: custom_fields).translate(condition_tree) end - self.custom_field_mapping = {} - - def initialize(timezone: nil) + def initialize(timezone: nil, custom_fields: {}) @timezone = timezone || 'UTC' + @custom_fields = custom_fields || {} end def translate(node) @@ -70,8 +65,8 @@ def translate_leaf(leaf) case leaf.operator when Operators::EQUAL then "#{field}:#{format_value(value)}" when Operators::NOT_EQUAL then "-#{field}:#{format_value(value)}" - when Operators::IN then Array(value).map { |v| "#{field}:#{format_value(v)}" }.join(' ') - when Operators::NOT_IN then Array(value).map { |v| "-#{field}:#{format_value(v)}" }.join(' ') + 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}:*" @@ -82,8 +77,23 @@ def translate_leaf(leaf) 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) - self.class.custom_field_mapping[field] || field + @custom_fields[field] || field end def format_value(value) @@ -119,8 +129,14 @@ def format_date(value) 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) - value.match?(/\s/) ? %("#{value}") : value + return value unless value.match?(/[\s"]/) + + %("#{value.gsub('"', '\\"')}") 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 index 6c3fe9be8..f5de744c0 100644 --- 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 @@ -84,6 +84,22 @@ 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, 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 index ac9a8f940..3ed6515ad 100644 --- 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 @@ -3,8 +3,11 @@ Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf - let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } - let(:datasource) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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) 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 index a8dc6fb3f..1c9764744 100644 --- 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 @@ -5,8 +5,11 @@ Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf - let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } - let(:datasource) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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) 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 index 663956cf9..96513e5ab 100644 --- 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 @@ -3,8 +3,11 @@ Aggregation = ForestAdminDatasourceToolkit::Components::Query::Aggregation Leaf = ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes::ConditionTreeLeaf - let(:client) { instance_double(ForestAdminDatasourceZendesk::Client) } - let(:datasource) { instance_double(ForestAdminDatasourceZendesk::Datasource, client: client) } + 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) 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 index 316d07995..8d9162442 100644 --- 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 @@ -41,7 +41,7 @@ expect(ds.get_collection('ZendeskTicket').schema[:fields]).to have_key('custom_7700') end - it 'populates the translator custom_field_mapping for ticket custom fields' do + 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' => [ @@ -49,9 +49,8 @@ ] }.to_json, headers: { 'Content-Type' => 'application/json' }) - described_class.new(**valid_args) - mapping = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping - expect(mapping['custom_7700']).to eq('custom_field_7700') + 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 @@ -62,8 +61,29 @@ ] }.to_json, headers: { 'Content-Type' => 'application/json' }) - described_class.new(**valid_args) - mapping = ForestAdminDatasourceZendesk::Query::ConditionTreeTranslator.custom_field_mapping - expect(mapping['tier']).to eq('tier') + 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 index 5ec4f9fa3..005fc5c27 100644 --- 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 @@ -92,6 +92,30 @@ def translate(node) 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 @@ -122,20 +146,35 @@ def translate(node) end describe 'custom field mapping' do - around do |ex| - previous = described_class.custom_field_mapping - described_class.custom_field_mapping = { 'custom_360001' => 'custom_field_360001' } - ex.run - described_class.custom_field_mapping = previous - end + let(:mapping) { { 'custom_360001' => 'custom_field_360001' } } it 'rewrites a custom field column name to the Zendesk Search field' do - expect(translate(Leaf.new('custom_360001', 'equal', 'gold'))) - .to eq('custom_field_360001:gold') + 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 - expect(translate(Leaf.new('status', 'equal', 'open'))).to eq('status:open') + 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 From 116f90975cecb92ef91d584512c9551f66015d56 Mon Sep 17 00:00:00 2001 From: Pierre Merlet Date: Wed, 29 Apr 2026 10:36:26 +0200 Subject: [PATCH 6/6] ci(zendesk): wire forest_admin_datasource_zendesk into release pipeline build.yml: - Add the package to the lint matrix (RuboCop runs against every package's tree). - Add to the test matrix; CI runs rspec under BUNDLE_GEMFILE=Gemfile- test like the other datasource packages. - Add the coverage.json path so qlty consumes our coverage too. Gemfile-test: pulls forest_admin_datasource_toolkit from the local sibling package (matches the active_record / mongoid pattern). Adds simplecov-html, simplecov_json_formatter and webmock so the CI run emits the JSON coverage file qlty expects. spec_helper.rb: load simplecov-html / simplecov_json_formatter inside a guarded require so local `bundle exec rspec` (no formatters) still works while CI's Gemfile-test path emits coverage.json. .releaserc.js: - prepareCmd bumps the package's version.rb alongside the others. - successCmd builds and pushes the gem to RubyGems. - @semantic-release/git tracks the version.rb in the release commit. Tested locally under both Gemfile and Gemfile-test: 144 specs pass, RuboCop clean, JSON coverage emitted. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build.yml | 4 +++- .releaserc.js | 7 +++++-- .../forest_admin_datasource_zendesk/Gemfile-test | 16 ++++++++++++++++ .../spec/spec_helper.rb | 13 +++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 packages/forest_admin_datasource_zendesk/Gemfile-test 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/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/spec/spec_helper.rb b/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb index d6b162fb8..561c180a8 100644 --- a/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_zendesk/spec/spec_helper.rb @@ -1,10 +1,23 @@ 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'