-
Notifications
You must be signed in to change notification settings - Fork 1
feat(zendesk): smart actions for ticket creation and closure #303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b95c090
57f68ff
fc9076e
a731e0a
49af998
27a8df1
21ceb62
a8ec9b6
8aca51b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| # The Zendesk ticket id is read from a configurable column on the host | ||
| # record(s); Zendesk sometimes rejects the direct `open -> closed` | ||
| # transition so failures are surfaced per-id rather than retried. | ||
| class CloseTicket < ForestAdminDatasourceCustomizer::Plugins::Plugin | ||
| BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction | ||
| ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope | ||
|
|
||
| STATUSES = %w[solved closed].freeze | ||
| SCOPE_KEYS = %i[single bulk].freeze | ||
|
|
||
| # Zendesk refuses any update on a closed ticket with this exact | ||
| # wording — detected so we can swap the raw stack for a clean message. | ||
| ALREADY_CLOSED_PATTERN = 'closed prevents ticket update'.freeze | ||
|
|
||
| NAMES = { | ||
| 'solved' => { single: 'Mark Zendesk ticket as solved', | ||
| bulk: 'Mark selected Zendesk tickets as solved' }.freeze, | ||
| 'closed' => { single: 'Mark Zendesk ticket as closed', | ||
| bulk: 'Mark selected Zendesk tickets as closed' }.freeze | ||
| }.freeze | ||
|
|
||
| SCOPES = { single: ActionScope::SINGLE, bulk: ActionScope::BULK }.freeze | ||
|
|
||
| def run(_datasource_customizer, collection_customizer = nil, options = {}) | ||
| datasource = options[:datasource] | ||
| ticket_id_field = options[:ticket_id_field] | ||
| raise ArgumentError, 'CloseTicket plugin requires :datasource' unless datasource | ||
| raise ArgumentError, 'CloseTicket plugin requires :ticket_id_field' unless ticket_id_field | ||
| raise ArgumentError, 'CloseTicket plugin requires a collection' unless collection_customizer | ||
|
|
||
| statuses = normalize_statuses(options[:statuses]) | ||
| scopes = normalize_scopes(options[:scopes]) | ||
|
|
||
| variants(statuses, scopes).each do |name, status, scope| | ||
| collection_customizer.add_action(name, build_action(datasource, status, scope, ticket_id_field)) | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def normalize_statuses(value) | ||
| list = Array(value).map(&:to_s).uniq | ||
| list = STATUSES if list.empty? | ||
| unknown = list - STATUSES | ||
| return list if unknown.empty? | ||
|
|
||
| raise ForestAdminDatasourceToolkit::Exceptions::ForestException, | ||
| "Unknown CloseTicket statuses: #{unknown.join(", ")}. Allowed: #{STATUSES.join(", ")}." | ||
| end | ||
|
|
||
| def normalize_scopes(value) | ||
| list = Array(value).map(&:to_sym).uniq | ||
| list = SCOPE_KEYS if list.empty? | ||
| unknown = list - SCOPE_KEYS | ||
| return list if unknown.empty? | ||
|
|
||
| raise ForestAdminDatasourceToolkit::Exceptions::ForestException, | ||
| "Unknown CloseTicket scopes: #{unknown.join(", ")}. Allowed: #{SCOPE_KEYS.join(", ")}." | ||
| end | ||
|
|
||
| def variants(statuses, scopes) | ||
| statuses.flat_map do |status| | ||
| scopes.map { |scope_key| [NAMES[status][scope_key], status, SCOPES[scope_key]] } | ||
| end | ||
| end | ||
|
|
||
| def build_action(datasource, status, scope, ticket_id_field) | ||
| BaseAction.new(scope: scope, &executor(datasource, status, ticket_id_field)) | ||
| end | ||
|
|
||
| def executor(datasource, status, ticket_id_field) | ||
| lambda do |context, result_builder| | ||
| ids = resolve_ticket_ids(context, ticket_id_field) | ||
| next result_builder.error(message: "No Zendesk ticket id found in '#{ticket_id_field}'.") if ids.empty? | ||
|
|
||
| succeeded, already_closed, failed = apply_status(datasource, ids, status) | ||
|
|
||
| # Closed tickets can't be reopened to 'solved'; fold into failures. | ||
| if status == 'solved' | ||
| failed += already_closed.map { |id| [id, 'ticket is already closed (cannot reopen to mark as solved)'] } | ||
| already_closed = [] | ||
| end | ||
|
|
||
| if succeeded.empty? && already_closed.empty? | ||
| result_builder.error(message: Messages.error(failed, status)) | ||
| else | ||
| result_builder.success(message: Messages.success(succeeded, already_closed, failed, status)) | ||
| end | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def resolve_ticket_ids(context, ticket_id_field) | ||
| records = context.get_records([ticket_id_field]) | ||
| records = [records].compact unless records.is_a?(Array) | ||
| records.filter_map { |r| r[ticket_id_field] || r[ticket_id_field.to_sym] } | ||
| rescue StandardError => e | ||
| ForestAdminDatasourceZendesk.logger.warn( | ||
| "[forest_admin_datasource_zendesk] failed to resolve ticket ids from '#{ticket_id_field}': " \ | ||
| "#{e.class}: #{e.message}" | ||
| ) | ||
| [] | ||
| end | ||
|
|
||
| # Per-id rescue so a single transition rejection doesn't abort bulk. | ||
| def apply_status(datasource, ids, status) | ||
| succeeded = [] | ||
| already_closed = [] | ||
| failed = [] | ||
| ids.each do |id| | ||
| datasource.client.update_ticket(id, 'status' => status) | ||
| succeeded << id | ||
| rescue StandardError => e | ||
| if already_closed?(e) | ||
| already_closed << id | ||
| else | ||
| ForestAdminDatasourceZendesk.logger.warn( | ||
| "[forest_admin_datasource_zendesk] failed to set ticket ##{id} to '#{status}': " \ | ||
| "#{e.class}: #{e.message}" | ||
| ) | ||
| failed << [id, "#{e.class}: #{e.message}"] | ||
| end | ||
| end | ||
| [succeeded, already_closed, failed] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def already_closed?(error) | ||
| error.message.to_s.include?(ALREADY_CLOSED_PATTERN) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| class CloseTicket | ||
| module Messages | ||
| module_function | ||
|
|
||
| def success(succeeded, already_closed, failed, status) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| [succeeded_phrase(succeeded, status), already_closed_phrase(already_closed), | ||
| failed_phrase(failed)].compact.join(' ') | ||
| end | ||
|
|
||
| def error(failed, status) | ||
| verb = status == 'closed' ? 'close' : 'mark as solved' | ||
| return "Failed to #{verb} ticket ##{failed.first.first}: #{failed.first.last}" if failed.size == 1 | ||
|
|
||
| "Failed to #{verb} all #{failed.size} tickets. First error: #{failed.first.last}" | ||
| end | ||
|
|
||
| def succeeded_phrase(succeeded, status) | ||
| return nil if succeeded.empty? | ||
|
|
||
| verb = status == 'closed' ? 'closed' : 'marked as solved' | ||
| succeeded.size == 1 ? "Ticket ##{succeeded.first} #{verb}." : "#{succeeded.size} tickets #{verb}." | ||
| end | ||
|
|
||
| def already_closed_phrase(already_closed) | ||
| return nil if already_closed.empty? | ||
| return "Ticket ##{already_closed.first} was already closed." if already_closed.size == 1 | ||
|
|
||
| "#{already_closed.size} tickets were already closed: #{already_closed.join(", ")}." | ||
| end | ||
|
|
||
| def failed_phrase(failed) | ||
| return nil if failed.empty? | ||
|
|
||
| "#{failed.size} failed: #{failed.map(&:first).join(", ")}." | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| module ForestAdminDatasourceZendesk | ||
| module Plugins | ||
| # Zendesk creates the requester user on the fly from the form's email, | ||
| # so the action can be registered on any host collection — no relation | ||
| # to Zendesk needed. | ||
| class CreateTicketWithNotification < ForestAdminDatasourceCustomizer::Plugins::Plugin | ||
| BaseAction = ForestAdminDatasourceCustomizer::Decorators::Action::BaseAction | ||
| ActionScope = ForestAdminDatasourceCustomizer::Decorators::Action::Types::ActionScope | ||
|
|
||
| NAME = 'Create ticket and notify'.freeze | ||
|
|
||
| def run(_datasource_customizer, collection_customizer = nil, options = {}) | ||
| datasource = options[:datasource] | ||
| raise ArgumentError, 'CreateTicketWithNotification plugin requires :datasource' unless datasource | ||
| raise ArgumentError, 'CreateTicketWithNotification plugin requires a collection' unless collection_customizer | ||
|
|
||
| opts = options.except(:datasource) | ||
| opts[:email_templates] = Array(opts[:email_templates]).compact | ||
| collection_customizer.add_action(opts[:action_name] || NAME, build_action(datasource, opts)) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def build_action(datasource, opts) | ||
| BaseAction.new(scope: ActionScope::SINGLE, form: FormBuilder.build(opts), &executor(datasource, opts)) | ||
| end | ||
|
|
||
| def executor(datasource, opts) | ||
| lambda do |context, result_builder| | ||
| values = context.form_values | ||
| email = values['Requester email'] | ||
| next result_builder.error(message: 'Requester email is required.') unless present?(email) | ||
|
|
||
| payload = build_payload(values, email, opts) | ||
| ticket = datasource.client.create_ticket(payload) | ||
| ticket_id = ticket.respond_to?(:[]) ? ticket['id'] : nil | ||
|
|
||
| writeback = write_back_ticket_id(context, opts[:ticket_id_field], ticket_id) | ||
| result_builder.success(message: success_message(ticket_id, values, writeback)) | ||
| end | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def build_payload(values, email, opts) | ||
| internal_note = truthy?(values['Send as internal note']) | ||
| payload = { | ||
| # Zendesk's create-user-on-the-fly requires a non-empty `name`; | ||
| # derive from the email's local-part. Ignored if the user exists. | ||
| 'requester' => { 'email' => email, 'name' => derive_requester_name(email) }, | ||
| 'subject' => values['Subject'], | ||
| 'comment' => { 'html_body' => values['Message'], 'public' => !internal_note } | ||
| } | ||
| priority = present?(opts[:priority_override]) ? opts[:priority_override] : values['Priority'] | ||
| type = present?(opts[:type_override]) ? opts[:type_override] : values['Type'] | ||
| payload['priority'] = priority if present?(priority) | ||
| payload['type'] = type if present?(type) | ||
| # Zendesk's `recipient` = the support address replies come FROM. | ||
| payload['recipient'] = opts[:sender_email] if present?(opts[:sender_email]) | ||
| payload | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| def derive_requester_name(email) | ||
| local = email.to_s.split('@').first.to_s | ||
| local.empty? ? email.to_s : local | ||
| end | ||
|
|
||
| # Best-effort: a writeback failure mustn't roll back the Zendesk ticket. | ||
| def write_back_ticket_id(context, field, ticket_id) | ||
| return :skipped if field.nil? || ticket_id.nil? | ||
|
|
||
| context.collection.update(context.filter, { field => ticket_id }) | ||
| :ok | ||
| rescue StandardError => e | ||
| ForestAdminDatasourceZendesk.logger.warn( | ||
| "[forest_admin_datasource_zendesk] failed to store ticket id in '#{field}': #{e.class}: #{e.message}" | ||
| ) | ||
| [:failed, "#{e.class}: #{e.message}"] | ||
| end | ||
|
|
||
| def success_message(ticket_id, values, writeback = :skipped) | ||
| base = base_success_message(ticket_id, values) | ||
| return base unless writeback.is_a?(Array) && writeback.first == :failed | ||
|
|
||
| "#{base} (warning: could not store the ticket id on the record: #{writeback.last})" | ||
| end | ||
|
|
||
| def base_success_message(ticket_id, values) | ||
| if truthy?(values['Send as internal note']) | ||
| return 'Ticket created (internal note).' unless ticket_id | ||
|
|
||
| "Ticket ##{ticket_id} created (internal note, no email)." | ||
| else | ||
| return 'Ticket created and requester notified.' unless ticket_id | ||
|
|
||
| "Ticket ##{ticket_id} created and requester notified." | ||
| end | ||
| end | ||
|
|
||
| def truthy?(value) | ||
| value == true || value.to_s.casecmp('true').zero? | ||
| end | ||
|
|
||
| def present?(value) | ||
| !value.nil? && value.to_s != '' | ||
| end | ||
|
Comment on lines
+102
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low The def present?(value)
- !value.nil? && value.to_s != ''
+ !value.nil? && value.to_s.strip != ''
end🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| end | ||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Function with many parameters (count = 4): build_action [qlty:function-parameters]