support_desk 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +105 -10
- data/Rakefile +16 -0
- data/app/controllers/support_desk/tickets_controller.rb +5 -3
- data/app/helpers/support_desk/engine_helper.rb +7 -1
- data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
- data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
- data/app/views/support_desk/console/tickets/index.html.erb +14 -2
- data/app/views/support_desk/console/tickets/new.html.erb +25 -0
- data/config/console_routes.rb +1 -1
- data/config/locales/support_desk.console.en.yml +26 -0
- data/config/locales/support_desk.console.es.yml +26 -0
- data/config/locales/support_desk.en.yml +3 -0
- data/config/locales/support_desk.es.yml +6 -0
- data/lib/generators/support_desk/console_generator.rb +1 -1
- data/lib/generators/support_desk/install_generator.rb +14 -0
- data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
- data/lib/generators/support_desk/templates/initializer.rb +30 -0
- data/lib/generators/support_desk/upgrade_generator.rb +56 -0
- data/lib/support_desk/configuration.rb +148 -2
- data/lib/support_desk/console.rb +347 -25
- data/lib/support_desk/console_engine.rb +2 -0
- data/lib/support_desk/console_routes.rb +20 -8
- data/lib/support_desk/context_card.rb +23 -0
- data/lib/support_desk/doctor.rb +51 -1
- data/lib/support_desk/errors.rb +5 -0
- data/lib/support_desk/macros.rb +28 -12
- data/lib/support_desk/models/assignment.rb +3 -1
- data/lib/support_desk/models/concerns/agent.rb +39 -4
- data/lib/support_desk/models/concerns/requester.rb +27 -12
- data/lib/support_desk/models/ticket.rb +464 -100
- data/lib/support_desk/summary.rb +1 -1
- data/lib/support_desk/test_helpers.rb +8 -3
- data/lib/support_desk/version.rb +1 -1
- data/lib/support_desk/wizard.rb +4 -12
- data/lib/support_desk.rb +45 -0
- data/lib/tasks/support_desk.rake +27 -0
- metadata +7 -2
|
@@ -8,6 +8,11 @@ es:
|
|
|
8
8
|
closed_notice: "Esta conversación está cerrada. Abre una nueva si necesitas algo más."
|
|
9
9
|
reopened_notice: "Hemos vuelto a abrir esta conversación."
|
|
10
10
|
promise: "Normalmente respondemos en menos de %{time}"
|
|
11
|
+
# La línea con la que se abre un hilo que ha empezado el equipo: quien
|
|
12
|
+
# recibe un mensaje de un sitio al que nunca ha escrito necesita saber
|
|
13
|
+
# qué es esto.
|
|
14
|
+
opened_by_support: "%{desk} ha abierto esta conversación contigo sobre «%{label}». Puedes responder aquí mismo."
|
|
15
|
+
unavailable_notice: "Esta cuenta ya no puede enviar ni recibir mensajes. La conversación se queda aquí para consultarla."
|
|
11
16
|
notifications:
|
|
12
17
|
# Generic on purpose: una pantalla de bloqueo no debe contar de qué va
|
|
13
18
|
# el caso de nadie. El detalle va en el cuerpo.
|
|
@@ -45,6 +50,7 @@ es:
|
|
|
45
50
|
state:
|
|
46
51
|
awaiting_reply: "Estamos con ello"
|
|
47
52
|
answered: "Te hemos contestado"
|
|
53
|
+
opened_by_support: "Te hemos escrito"
|
|
48
54
|
closed: "Cerrada"
|
|
49
55
|
# The ways in, from anywhere in the host app.
|
|
50
56
|
doors:
|
|
@@ -60,7 +60,7 @@ module SupportDesk
|
|
|
60
60
|
say " 1. One route line:"
|
|
61
61
|
say " # config/routes.rb"
|
|
62
62
|
say " namespace :#{target} do"
|
|
63
|
-
say " resources :support_tickets, only: %i[index show], concerns: :support_console"
|
|
63
|
+
say " resources :support_tickets, only: %i[index show new], concerns: :support_console"
|
|
64
64
|
say " end"
|
|
65
65
|
|
|
66
66
|
say " 2. A badge in your admin nav:"
|
|
@@ -27,6 +27,15 @@ module SupportDesk
|
|
|
27
27
|
File.join(db_migrate_path, "create_support_desk_tables.rb")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Who opened each case. A SEPARATE migration, copied from the same
|
|
31
|
+
# template `support_desk:upgrade` hands an existing install, so one
|
|
32
|
+
# file owns those two columns and their index wherever they came from
|
|
33
|
+
# — and a fresh install's `down` removes exactly what its `up` added.
|
|
34
|
+
def create_opened_by_migration
|
|
35
|
+
migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
|
|
36
|
+
File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
# The annotated initializer — every setting the gem has, with what it
|
|
31
40
|
# means and what it defaults to.
|
|
32
41
|
def create_initializer
|
|
@@ -72,6 +81,11 @@ module SupportDesk
|
|
|
72
81
|
say " # app/mailboxes/application_mailbox.rb"
|
|
73
82
|
say " # routing(/^support@/i => :support_desk)"
|
|
74
83
|
|
|
84
|
+
say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
|
|
85
|
+
say " only the migrations the new version needs (0.2.0: who opened the case), and"
|
|
86
|
+
say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
|
|
87
|
+
say " support traffic, drain old web/workers, backfill, then serve only 0.2."
|
|
88
|
+
|
|
75
89
|
say "\nCheck your work any time with SupportDesk.doctor.print"
|
|
76
90
|
say "You now have support tickets that are real conversations. 🚀\n", :green
|
|
77
91
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# support_desk 0.2.0 — WHO OPENED THE CASE.
|
|
4
|
+
#
|
|
5
|
+
# `opened_by` is a record, exactly like `closed_by`: the requester when they
|
|
6
|
+
# asked, the agent when the desk wrote first. It is nullable for one reason
|
|
7
|
+
# only — rows written by 0.1.x have no provenance to state — and this
|
|
8
|
+
# migration backfills every one of them to its requester, because 0.1 had no
|
|
9
|
+
# way to open a case as anybody else.
|
|
10
|
+
#
|
|
11
|
+
# The new column's type is read from the tickets table's OWN `requester_id`
|
|
12
|
+
# rather than from today's generator setting: both columns point at the same
|
|
13
|
+
# host records, so an app that changed `primary_key_type` after installing
|
|
14
|
+
# would otherwise be handed a bigint column for uuid ids.
|
|
15
|
+
class AddOpenedByToSupportDeskTickets < ActiveRecord::Migration<%= migration_version %>
|
|
16
|
+
INDEX_NAME = "index_support_desk_tickets_on_opened_by"
|
|
17
|
+
|
|
18
|
+
def up
|
|
19
|
+
ensure_columns_are_ours!
|
|
20
|
+
|
|
21
|
+
add_reference :support_desk_tickets, :opened_by, polymorphic: true, null: true,
|
|
22
|
+
type: opened_by_id_type, index: false
|
|
23
|
+
add_index :support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME
|
|
24
|
+
|
|
25
|
+
# SQL, and deliberately not the model: a backfill that loaded today's
|
|
26
|
+
# Ticket would run this release's validations, callbacks and events over
|
|
27
|
+
# last release's rows, in a migration that must only move two columns.
|
|
28
|
+
execute(<<~SQL.squish)
|
|
29
|
+
UPDATE support_desk_tickets
|
|
30
|
+
SET opened_by_type = requester_type,
|
|
31
|
+
opened_by_id = requester_id
|
|
32
|
+
WHERE opened_by_id IS NULL
|
|
33
|
+
SQL
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Exactly what `up` added, and nothing else. Note that this DESTROYS
|
|
37
|
+
# provenance: every case the desk opened stops being distinguishable from
|
|
38
|
+
# one the requester opened. It is here for a failed deploy of the schema
|
|
39
|
+
# alone, never as the way back from a code rollback.
|
|
40
|
+
def down
|
|
41
|
+
remove_index :support_desk_tickets, name: INDEX_NAME if
|
|
42
|
+
index_exists?(:support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME)
|
|
43
|
+
|
|
44
|
+
remove_column :support_desk_tickets, :opened_by_type if
|
|
45
|
+
column_exists?(:support_desk_tickets, :opened_by_type)
|
|
46
|
+
remove_column :support_desk_tickets, :opened_by_id if
|
|
47
|
+
column_exists?(:support_desk_tickets, :opened_by_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# A column we did not add is a schema we cannot reason about: its type may
|
|
53
|
+
# not match, it may hold something else's data, and `down` would drop it.
|
|
54
|
+
# Say so with the way out rather than skipping the backfill and the index
|
|
55
|
+
# in silence.
|
|
56
|
+
def ensure_columns_are_ours!
|
|
57
|
+
existing = %i[opened_by_type opened_by_id].select { |name| column_exists?(:support_desk_tickets, name) }
|
|
58
|
+
return if existing.empty?
|
|
59
|
+
|
|
60
|
+
raise ActiveRecord::MigrationError,
|
|
61
|
+
"support_desk_tickets already has #{existing.join(" and ")}. This migration owns those two columns " \
|
|
62
|
+
"and #{INDEX_NAME}, so it won't overwrite them. Verify both column types, the index and existing " \
|
|
63
|
+
"provenance against this migration before reconciling its version in schema_migrations. " \
|
|
64
|
+
"db:migrate:up runs this method again; it does not mark it as already run. Preserve existing data."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The storage this table already uses for a host record's id — uuid,
|
|
68
|
+
# bigint, integer. `opened_by_id` points at the same records as
|
|
69
|
+
# `requester_id`, so it has to be stored the same way.
|
|
70
|
+
def opened_by_id_type
|
|
71
|
+
column = connection.columns(:support_desk_tickets).find { |candidate| candidate.name == "requester_id" }
|
|
72
|
+
unless column
|
|
73
|
+
raise ActiveRecord::MigrationError,
|
|
74
|
+
"support_desk_tickets has no requester_id column — run support_desk's install migration first."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
column.sql_type
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -16,7 +16,7 @@ module <%= module_name %>
|
|
|
16
16
|
# The routes come from the `:support_console` routing concern:
|
|
17
17
|
#
|
|
18
18
|
# namespace :<%= target %> do
|
|
19
|
-
# resources :support_tickets, only: %i[index show], concerns: :support_console
|
|
19
|
+
# resources :support_tickets, only: %i[index show new], concerns: :support_console
|
|
20
20
|
# end
|
|
21
21
|
#
|
|
22
22
|
# Both `index` and `show` are yours — the gem deliberately doesn't own the
|
|
@@ -19,6 +19,23 @@ SupportDesk.configure do |config|
|
|
|
19
19
|
#
|
|
20
20
|
# config.agents { User.where(admin: true) }
|
|
21
21
|
|
|
22
|
+
# Who may ask, and who may answer, record by record — the macros' `if:`:
|
|
23
|
+
#
|
|
24
|
+
# class User < ApplicationRecord
|
|
25
|
+
# has_support_tickets if: :kept? # a closed account can neither
|
|
26
|
+
# acts_as_support_agent if: :admin? # ask nor be written to
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# `has_support_tickets if:` is a WRITE rule, not a screen rule: the thread
|
|
30
|
+
# and its history stay readable when it turns false.
|
|
31
|
+
|
|
32
|
+
# How the console finds the person an agent types in "Write to someone":
|
|
33
|
+
# email, phone, handle — your call. Given the typed string, return a
|
|
34
|
+
# requester record or nil. Without it the console accepts only a GlobalID
|
|
35
|
+
# from one of your own pages.
|
|
36
|
+
#
|
|
37
|
+
# config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
|
|
38
|
+
|
|
22
39
|
# ==========================================================================
|
|
23
40
|
# CONTROLLER INTEGRATION
|
|
24
41
|
# ==========================================================================
|
|
@@ -105,6 +122,19 @@ SupportDesk.configure do |config|
|
|
|
105
122
|
#
|
|
106
123
|
# config.announce_assignments = :first_only
|
|
107
124
|
#
|
|
125
|
+
# The system line a thread opens with, posted inside the opening
|
|
126
|
+
# transaction and before the first message. A String with %{label},
|
|
127
|
+
# %{desk} and %{reply_within}, a Symbol naming an I18n key, a block given
|
|
128
|
+
# the ticket, or nil for no line at all.
|
|
129
|
+
#
|
|
130
|
+
# The second one is for a case the DESK opened
|
|
131
|
+
# (`lucia.open_support_conversation_with!(alice, "Vimos que…")`): it has a
|
|
132
|
+
# default, because a message from a desk somebody never wrote to has to
|
|
133
|
+
# explain itself.
|
|
134
|
+
#
|
|
135
|
+
# config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
|
|
136
|
+
# config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
|
|
137
|
+
#
|
|
108
138
|
# What a requester writing into a closed ticket does:
|
|
109
139
|
# :reopen_on_reply the case comes back (no wall, no dead end)
|
|
110
140
|
# :locked the composer is replaced by a notice
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module SupportDesk
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails generate support_desk:upgrade` — copy the migrations a version
|
|
9
|
+
# bump needs into an EXISTING install. Nothing else: the initializer, the
|
|
10
|
+
# views, the console and the routes you already own stay untouched.
|
|
11
|
+
#
|
|
12
|
+
# Currently writes the 0.2.0 migration (who opened the case), which is the
|
|
13
|
+
# SAME template a fresh install runs — one file owns those columns, so a
|
|
14
|
+
# fresh install and an upgraded one end up with the same schema and the
|
|
15
|
+
# same rollback. Running this twice writes nothing the second time: the
|
|
16
|
+
# migration already sitting in db/migrate is identical, and Rails skips it.
|
|
17
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
18
|
+
include ActiveRecord::Generators::Migration
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by)"
|
|
22
|
+
|
|
23
|
+
# Rails' migration numbering, borrowed from ActiveRecord's generators.
|
|
24
|
+
def self.next_migration_number(dir)
|
|
25
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_opened_by_migration
|
|
29
|
+
migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
|
|
30
|
+
File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def display_post_upgrade_message
|
|
34
|
+
say "\n🎫 support_desk upgrade migrations copied.", :green
|
|
35
|
+
say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
|
|
36
|
+
say " case at its requester — 0.1 had no other way to open one."
|
|
37
|
+
say " 2. New in 0.2.0:"
|
|
38
|
+
say " lucia.open_support_conversation_with!(alice, \"Vimos que…\") # the desk writes first"
|
|
39
|
+
say " ticket.opened_by / opened_by_support? / opened_by_requester?"
|
|
40
|
+
say " config.opening_line / config.opening_line_from_support / config.find_requester"
|
|
41
|
+
say " has_support_tickets if: :kept? # who may be written to"
|
|
42
|
+
say " 3. Migrate BEFORE 0.2 serves traffic. Pause support writes; stop and drain"
|
|
43
|
+
say " ALL old web requests and workers. This is NOT a rolling deployment."
|
|
44
|
+
say " Keep traffic paused, run 'rake support_desk:backfill_opened_by' under 0.2,"
|
|
45
|
+
say " verify no NULL openers remain, then start only 0.2 and resume traffic."
|
|
46
|
+
say " 4. See the CHANGELOG for the full list.\n", :green
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def migration_version
|
|
52
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -50,10 +50,21 @@ module SupportDesk
|
|
|
50
50
|
# fails at boot instead of silently leaving tickets unassigned.
|
|
51
51
|
UNRELEASED_ROUTING_STRATEGIES = %i[round_robin least_loaded].freeze
|
|
52
52
|
|
|
53
|
+
# The sample a static opening line is interpolated against the moment it
|
|
54
|
+
# is assigned, so a typo'd %{labe} is a boot failure and not a 3am
|
|
55
|
+
# exception in the middle of somebody opening a case.
|
|
56
|
+
LINE_INTERPOLATIONS = { label: "…", desk: "…", reply_within: "…" }.freeze
|
|
57
|
+
|
|
53
58
|
DEFAULTS = {
|
|
54
59
|
name: nil,
|
|
55
60
|
avatar: nil,
|
|
56
61
|
email: nil,
|
|
62
|
+
opening_line: nil,
|
|
63
|
+
# A message from a desk you never wrote to has to explain itself, so
|
|
64
|
+
# this one has a default and `opening_line` does not: existing hosts'
|
|
65
|
+
# threads keep opening exactly as they do today.
|
|
66
|
+
opening_line_from_support: :"support_desk.thread.opened_by_support",
|
|
67
|
+
find_requester: nil,
|
|
57
68
|
reply_policy: :anyone,
|
|
58
69
|
announce_assignments: :first_only,
|
|
59
70
|
closed_tickets: :reopen_on_reply,
|
|
@@ -116,6 +127,109 @@ module SupportDesk
|
|
|
116
127
|
@settings[:email] = value&.to_s
|
|
117
128
|
end
|
|
118
129
|
|
|
130
|
+
# --- What a thread opens with ---------------------------------------------
|
|
131
|
+
|
|
132
|
+
# The system line every thread opens with, posted INSIDE the opening
|
|
133
|
+
# transaction and before the first message, so it can never arrive
|
|
134
|
+
# after the message it introduces (or not at all).
|
|
135
|
+
#
|
|
136
|
+
# A String with %{label}, %{desk} and %{reply_within}; a Symbol naming
|
|
137
|
+
# an I18n key that takes the same interpolations; a block given the
|
|
138
|
+
# ticket; or nil for no line at all, which is the default.
|
|
139
|
+
#
|
|
140
|
+
# config.opening_line = "Has abierto una conversación sobre «%{label}»."
|
|
141
|
+
# config.opening_line { |ticket| ticket.subject ? … : … }
|
|
142
|
+
def opening_line(&block)
|
|
143
|
+
return @settings[:opening_line] = block if block
|
|
144
|
+
|
|
145
|
+
read(:opening_line)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Set it, validating on assignment (see the reader above).
|
|
149
|
+
def opening_line=(value)
|
|
150
|
+
@settings[:opening_line] = ensure_line(value, "opening_line")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The same line for a case the DESK opened. Defaults to the gem's own
|
|
154
|
+
# I18n key, because somebody who never wrote to you needs to be told
|
|
155
|
+
# what this is.
|
|
156
|
+
def opening_line_from_support(&block)
|
|
157
|
+
return @settings[:opening_line_from_support] = block if block
|
|
158
|
+
|
|
159
|
+
read(:opening_line_from_support)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Set it, validating on assignment (see the reader above).
|
|
163
|
+
def opening_line_from_support=(value)
|
|
164
|
+
@settings[:opening_line_from_support] = ensure_line(value, "opening_line_from_support")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# How a console finds the person an agent types: email, phone, handle,
|
|
168
|
+
# whatever this host lets staff search by. Given the typed string,
|
|
169
|
+
# returns a requester record or nil.
|
|
170
|
+
#
|
|
171
|
+
# config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
|
|
172
|
+
#
|
|
173
|
+
# nil (the default) means the console accepts only a GlobalID from one
|
|
174
|
+
# of your own pages. Multi-tenant hosts scope BOTH ways in — this hook
|
|
175
|
+
# is a lookup, never an authorization.
|
|
176
|
+
def find_requester(&block)
|
|
177
|
+
return @settings[:find_requester] = block if block
|
|
178
|
+
|
|
179
|
+
read(:find_requester)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Set it, validating on assignment (see the reader above).
|
|
183
|
+
def find_requester=(value)
|
|
184
|
+
@settings[:find_requester] = value.nil? ? nil : ensure_callable(value, "find_requester")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# What's wrong with this desk's opening lines, as sentences — what
|
|
188
|
+
# `doctor` reports. A String is interpolated against the sample, a
|
|
189
|
+
# Symbol has to exist in the current locale, and a block is left alone:
|
|
190
|
+
# it needs a ticket, and running a host's callback as a diagnostic is
|
|
191
|
+
# not a diagnostic.
|
|
192
|
+
def opening_line_problems # :nodoc:
|
|
193
|
+
%i[opening_line opening_line_from_support].filter_map do |setting|
|
|
194
|
+
value = public_send(setting)
|
|
195
|
+
next if value.nil? || value.respond_to?(:call)
|
|
196
|
+
|
|
197
|
+
if value.is_a?(Symbol)
|
|
198
|
+
next if I18n.exists?(value)
|
|
199
|
+
|
|
200
|
+
"#{setting} names #{value.inspect}, which has no #{I18n.locale} translation"
|
|
201
|
+
else
|
|
202
|
+
begin
|
|
203
|
+
interpolate_line(value, LINE_INTERPOLATIONS, setting.to_s)
|
|
204
|
+
nil
|
|
205
|
+
rescue ConfigurationError => e
|
|
206
|
+
e.message
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# What to post for THIS ticket, resolved and interpolated in the
|
|
213
|
+
# current locale. nil or blank means post nothing.
|
|
214
|
+
def opening_line_for(ticket)
|
|
215
|
+
setting = ticket.opened_by_support? ? opening_line_from_support : opening_line
|
|
216
|
+
return nil if setting.nil?
|
|
217
|
+
|
|
218
|
+
line = case setting
|
|
219
|
+
when Symbol then I18n.t(setting, **line_interpolations(ticket), raise: true)
|
|
220
|
+
when String then interpolate_line(setting, line_interpolations(ticket), "opening_line")
|
|
221
|
+
else setting.call(ticket)
|
|
222
|
+
end
|
|
223
|
+
return nil if line.nil?
|
|
224
|
+
|
|
225
|
+
unless line.is_a?(String)
|
|
226
|
+
raise ConfigurationError,
|
|
227
|
+
"an opening_line block must return a String or nil, got #{line.inspect}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
line
|
|
231
|
+
end
|
|
232
|
+
|
|
119
233
|
# --- Who answers ----------------------------------------------------------
|
|
120
234
|
|
|
121
235
|
# The agent pool: notified while a ticket is unassigned, offered in the
|
|
@@ -372,14 +486,46 @@ module SupportDesk
|
|
|
372
486
|
|
|
373
487
|
value
|
|
374
488
|
end
|
|
489
|
+
|
|
490
|
+
# nil, a String, an I18n key, or something to call. A String is
|
|
491
|
+
# interpolated here and now against the sample, so an unknown
|
|
492
|
+
# placeholder fails at boot rather than inside a transaction.
|
|
493
|
+
def ensure_line(value, name)
|
|
494
|
+
return value if value.nil? || value.is_a?(Symbol) || value.respond_to?(:call)
|
|
495
|
+
if value.is_a?(String)
|
|
496
|
+
interpolate_line(value, LINE_INTERPOLATIONS, name)
|
|
497
|
+
return value
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
raise ConfigurationError,
|
|
501
|
+
"#{name} must be a String, an I18n key (Symbol), a block, or nil, got #{value.inspect}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Named interpolation, NOT String#%: "100% ready" is ordinary copy in
|
|
505
|
+
# any language, and `%` would read that as a format directive and
|
|
506
|
+
# raise. I18n.interpolate leaves a literal percent alone and only
|
|
507
|
+
# touches %{named} placeholders.
|
|
508
|
+
def interpolate_line(line, interpolations, name)
|
|
509
|
+
I18n.interpolate(line, interpolations)
|
|
510
|
+
rescue KeyError, ArgumentError => e
|
|
511
|
+
raise ConfigurationError,
|
|
512
|
+
"#{name} can't be interpolated (#{e.class}: #{e.message}). The placeholders it can use are " \
|
|
513
|
+
"#{LINE_INTERPOLATIONS.keys.map { |key| "%{#{key}}" }.join(", ")}."
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def line_interpolations(ticket)
|
|
517
|
+
{ label: ticket.label, desk: ticket.desk.name,
|
|
518
|
+
reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
|
|
519
|
+
end
|
|
375
520
|
end
|
|
376
521
|
|
|
377
522
|
# Settings that belong to a desk rather than the installation. The
|
|
378
523
|
# top-level accessors forward to the `:default` desk, which is also what
|
|
379
524
|
# every other desk falls back to.
|
|
380
525
|
DESK_SETTINGS = %i[
|
|
381
|
-
name avatar email
|
|
382
|
-
|
|
526
|
+
name avatar email opening_line opening_line_from_support find_requester reply_policy
|
|
527
|
+
announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
|
|
528
|
+
inbox_entry routing mirror_replies_by_email auto_close_after
|
|
383
529
|
].freeze
|
|
384
530
|
|
|
385
531
|
delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)
|