support_desk 0.1.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 +7 -0
- data/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- metadata +229 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# An agent's view of a desk: the tabs, the counts, the badge, and what to
|
|
5
|
+
# work on next. Plain relations all the way down, so any UI — a console, a
|
|
6
|
+
# rake task, a Slack command — renders from the same object.
|
|
7
|
+
#
|
|
8
|
+
# q = lucia.support_queue
|
|
9
|
+
# q.awaiting # the "needs an answer" tab, as a relation
|
|
10
|
+
# q.counts # { mine: 2, unassigned: 3, … } in ONE query
|
|
11
|
+
# q.badge # the nav number
|
|
12
|
+
# q.next # the ticket to open now
|
|
13
|
+
# q.tabs # [[:awaiting, "Pendientes", 4], …]
|
|
14
|
+
class Queue
|
|
15
|
+
# Every tab a queue can answer for, in display order. `scope` and
|
|
16
|
+
# `counts` both cover all of them.
|
|
17
|
+
TABS = %i[awaiting mine unassigned open snoozed closed].freeze
|
|
18
|
+
|
|
19
|
+
# Tabs whose FEATURE hasn't shipped yet. Snoozing lands in 0.2: until a
|
|
20
|
+
# ticket can actually be snoozed, the tab is a permanent column of
|
|
21
|
+
# zeros, and a control that never does anything is how a console starts
|
|
22
|
+
# teaching people not to read it. The relation and the count stay — a
|
|
23
|
+
# host importing snoozed tickets from elsewhere can still ask for them
|
|
24
|
+
# by name — but nothing offers the tab.
|
|
25
|
+
UNRELEASED_TABS = %i[snoozed].freeze
|
|
26
|
+
|
|
27
|
+
# The tabs a console should render, in display order.
|
|
28
|
+
VISIBLE_TABS = (TABS - UNRELEASED_TABS).freeze
|
|
29
|
+
|
|
30
|
+
# How long a nav badge may lie. Long enough that a busy console isn't
|
|
31
|
+
# counting rows on every request, short enough that nobody notices.
|
|
32
|
+
BADGE_TTL = 30
|
|
33
|
+
|
|
34
|
+
attr_reader :agent, :desk
|
|
35
|
+
|
|
36
|
+
# The queue for +agent+ on +desk+ (the default desk when omitted).
|
|
37
|
+
def self.for(agent, desk: nil)
|
|
38
|
+
new(agent, desk: desk || SupportDesk.desk)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# An agent's view of one desk. Prefer `Queue.for` or
|
|
42
|
+
# `agent.support_queue`.
|
|
43
|
+
def initialize(agent, desk:)
|
|
44
|
+
@agent = agent
|
|
45
|
+
@desk = desk
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- Relations ---------------------------------------------------------------
|
|
49
|
+
#
|
|
50
|
+
# Every tab states its OWN order, and the base scope states none. An
|
|
51
|
+
# order baked into the base would win and every later `.order` would be
|
|
52
|
+
# a dead tiebreaker — which is how a queue quietly starts handing out
|
|
53
|
+
# the newest ticket when it promised the most urgent one.
|
|
54
|
+
|
|
55
|
+
# Every ticket on this desk, newest first — the unfiltered list.
|
|
56
|
+
def all
|
|
57
|
+
scoped.newest_first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Open and held by this agent, most urgent first.
|
|
61
|
+
def mine
|
|
62
|
+
scoped.open.assigned_to(agent).most_urgent_first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Open and held by nobody, most urgent first.
|
|
66
|
+
def unassigned
|
|
67
|
+
scoped.open.unassigned.most_urgent_first
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Open and waiting on the desk — the tab that means "work".
|
|
71
|
+
def awaiting
|
|
72
|
+
scoped.open.awaiting_reply.most_urgent_first
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Put aside until a date, the soonest to wake first (0.2).
|
|
76
|
+
def snoozed = scoped.snoozed.order(:snoozed_until)
|
|
77
|
+
|
|
78
|
+
# Every live case on this desk, held or not, most urgent first.
|
|
79
|
+
def open = scoped.open.most_urgent_first
|
|
80
|
+
|
|
81
|
+
# Done, most recently touched first.
|
|
82
|
+
def closed = scoped.closed.recent_activity_first
|
|
83
|
+
|
|
84
|
+
# --- Numbers -----------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
# Every tab's count, in one grouped query. Grouping by the state columns
|
|
87
|
+
# (status, awaiting, assignee) and adding up in Ruby costs one round
|
|
88
|
+
# trip; six `.count` calls cost six.
|
|
89
|
+
def counts
|
|
90
|
+
rows = scoped.group(:status, :awaiting, :assignee_type, :assignee_id).count
|
|
91
|
+
|
|
92
|
+
counts = TABS.index_with(0)
|
|
93
|
+
rows.each do |(status, awaiting, assignee_type, assignee_id), count|
|
|
94
|
+
assigned_to_me = agent_key == [ assignee_type, assignee_id.to_s ]
|
|
95
|
+
|
|
96
|
+
case status
|
|
97
|
+
when "open"
|
|
98
|
+
counts[:open] += count
|
|
99
|
+
counts[:mine] += count if assigned_to_me
|
|
100
|
+
counts[:unassigned] += count if assignee_id.nil?
|
|
101
|
+
counts[:awaiting] += count if awaiting == "agent"
|
|
102
|
+
when "snoozed" then counts[:snoozed] += count
|
|
103
|
+
when "closed" then counts[:closed] += count
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
counts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The nav badge: open tickets this agent should feel responsible for —
|
|
110
|
+
# theirs, plus everything nobody has picked up. Cached briefly per agent.
|
|
111
|
+
def badge
|
|
112
|
+
return badge_count unless defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
|
|
113
|
+
|
|
114
|
+
Rails.cache.fetch(badge_cache_key, expires_in: BADGE_TTL) { badge_count }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The ticket to open now: the most urgent among this agent's own and
|
|
118
|
+
# the unclaimed ones.
|
|
119
|
+
def next
|
|
120
|
+
mine_or_unassigned.most_urgent_first.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# [[:awaiting, "Pendientes", 4], …] — i18n labels, in display order,
|
|
124
|
+
# ready to render.
|
|
125
|
+
def tabs
|
|
126
|
+
numbers = counts
|
|
127
|
+
VISIBLE_TABS.map { |tab| [ tab, I18n.t("support_desk.queue.tabs.#{tab}"), numbers[tab] ] }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The relation behind a tab name, so a console can route `params[:tab]`
|
|
131
|
+
# without a case statement.
|
|
132
|
+
def scope(tab)
|
|
133
|
+
tab = (tab.presence || :awaiting).to_sym
|
|
134
|
+
raise ArgumentError, "unknown queue tab #{tab.inspect} (known: #{TABS.join(", ")})" unless TABS.include?(tab)
|
|
135
|
+
|
|
136
|
+
public_send(tab)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whose queue, on which desk.
|
|
140
|
+
def inspect
|
|
141
|
+
"#<SupportDesk::Queue desk=#{desk.key} agent=#{agent.class}##{agent.id}>"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# The base scope: this desk's tickets, in no particular order, so the
|
|
147
|
+
# caller's order is the one that counts.
|
|
148
|
+
def scoped
|
|
149
|
+
Ticket.where(desk: desk)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def mine_or_unassigned
|
|
153
|
+
scoped.open.where(
|
|
154
|
+
Ticket.arel_table[:assignee_id].eq(nil).or(
|
|
155
|
+
Ticket.arel_table[:assignee_type].eq(agent.class.polymorphic_name)
|
|
156
|
+
.and(Ticket.arel_table[:assignee_id].eq(agent.id))
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def badge_count = mine_or_unassigned.count
|
|
162
|
+
|
|
163
|
+
def agent_key
|
|
164
|
+
@agent_key ||= [ agent.class.polymorphic_name, agent.id.to_s ]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def badge_cache_key
|
|
168
|
+
[ "support_desk", "queue", desk.key, "badge", SupportDesk.actor_key(agent) ]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# One line about a ticket, for places that only have one line: a list row,
|
|
5
|
+
# a Slack message, the daily Telegram digest, `rails c`.
|
|
6
|
+
#
|
|
7
|
+
# ticket.summary.to_s
|
|
8
|
+
# # => "T-AB12CD · Viaje Sevilla → Granada · Alice · esperando respuesta (12 min)"
|
|
9
|
+
class Summary
|
|
10
|
+
attr_reader :ticket
|
|
11
|
+
|
|
12
|
+
# The one-line summary of a ticket.
|
|
13
|
+
def initialize(ticket)
|
|
14
|
+
@ticket = ticket
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The pieces, for hosts that want to lay them out themselves.
|
|
18
|
+
def reference = ticket.reference
|
|
19
|
+
def label = ticket.label
|
|
20
|
+
|
|
21
|
+
# Who is asking, and who is answering (nil while nobody is).
|
|
22
|
+
def requester_name
|
|
23
|
+
Chats.display_name_for(ticket.requester)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Who is answering, or nil while nobody is.
|
|
27
|
+
def assignee_name
|
|
28
|
+
ticket.assignee&.support_agent_name
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# "esperando respuesta" / "esperando al cliente" / "cerrado".
|
|
32
|
+
def state
|
|
33
|
+
return I18n.t("support_desk.summary.closed") if ticket.closed?
|
|
34
|
+
return I18n.t("support_desk.summary.awaiting_reply") if ticket.awaiting_reply?
|
|
35
|
+
return I18n.t("support_desk.summary.awaiting_requester") if ticket.awaiting_requester?
|
|
36
|
+
|
|
37
|
+
I18n.t("support_desk.summary.open")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# How long the current wait has been going on, as words ("12 minutes").
|
|
41
|
+
def waiting
|
|
42
|
+
duration = ticket.waiting_for
|
|
43
|
+
return nil if duration.nil?
|
|
44
|
+
|
|
45
|
+
# Through the same helper the wizard uses for its promise line, so
|
|
46
|
+
# this speaks the reader's language. Duration#inspect is English
|
|
47
|
+
# whatever the locale, which left one untranslatable string in an
|
|
48
|
+
# otherwise Spanish console.
|
|
49
|
+
Wizard.humanize_duration(duration)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The whole line.
|
|
53
|
+
def to_s
|
|
54
|
+
parts = [ reference, label, requester_name, state ]
|
|
55
|
+
parts << "(#{waiting})" if waiting
|
|
56
|
+
parts.compact.join(" · ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The same line, in pieces.
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
reference: reference, label: label, requester: requester_name, assignee: assignee_name,
|
|
63
|
+
state: state, waiting: waiting
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The summary, in one line.
|
|
68
|
+
def inspect = "#<SupportDesk::Summary #{to_s.inspect}>"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Test helpers for hosts. Include it in your `ActiveSupport::TestCase`:
|
|
5
|
+
#
|
|
6
|
+
# include SupportDesk::TestHelpers
|
|
7
|
+
#
|
|
8
|
+
# ticket = open_support_ticket(for: users(:alice), about: rides(:sevilla), message: "No aparece")
|
|
9
|
+
# reply_as users(:lucia), ticket, "Lo miramos"
|
|
10
|
+
# assert_awaiting_requester ticket
|
|
11
|
+
# assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
|
|
12
|
+
#
|
|
13
|
+
# with_support_config(reply_policy: :assignee_only) { … }
|
|
14
|
+
#
|
|
15
|
+
# The assertions read the same way the gem's own suite does, which is the
|
|
16
|
+
# point: your acceptance tests and ours describe the same behaviour.
|
|
17
|
+
module TestHelpers
|
|
18
|
+
# Open a ticket the way a requester would, and hand it back.
|
|
19
|
+
def open_support_ticket(message: "Necesito ayuda", about: nil, topic: nil, **options)
|
|
20
|
+
requester = options.fetch(:for) { raise ArgumentError, "open_support_ticket needs for: a requester" }
|
|
21
|
+
requester.ask_support!(message, about: about, topic: topic)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Answer as an agent. Returns the Chats::Message.
|
|
25
|
+
def reply_as(agent, ticket, body, files: [])
|
|
26
|
+
ticket.reply!(body, by: agent, files: files)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Say something else as the requester. Returns the Chats::Message.
|
|
30
|
+
def ask_again(ticket, body)
|
|
31
|
+
ticket.requester.message!(ticket.conversation, body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# --- Assertions -------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# The requester spoke last and the desk owes the next word.
|
|
37
|
+
def assert_awaiting_reply(ticket, message = nil)
|
|
38
|
+
ticket.reload
|
|
39
|
+
assert_predicate ticket, :awaiting_reply?,
|
|
40
|
+
message || "expected #{ticket.reference} to be waiting on the desk, was #{ticket.awaiting}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The desk answered and the ball is in the requester's court.
|
|
44
|
+
def assert_awaiting_requester(ticket, message = nil)
|
|
45
|
+
ticket.reload
|
|
46
|
+
assert_predicate ticket, :awaiting_requester?,
|
|
47
|
+
message || "expected #{ticket.reference} to be waiting on the requester, " \
|
|
48
|
+
"was #{ticket.awaiting}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The case is done.
|
|
52
|
+
def assert_ticket_closed(ticket, message = nil)
|
|
53
|
+
ticket.reload
|
|
54
|
+
assert_predicate ticket, :closed?, message || "expected #{ticket.reference} to be closed, was #{ticket.status}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The case is still live.
|
|
58
|
+
def assert_ticket_open(ticket, message = nil)
|
|
59
|
+
ticket.reload
|
|
60
|
+
assert_predicate ticket, :open?, message || "expected #{ticket.reference} to be open, was #{ticket.status}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Who is holding the case, or that nobody is.
|
|
64
|
+
def assert_assigned_to(ticket, agent, message = nil)
|
|
65
|
+
ticket.reload
|
|
66
|
+
assert ticket.assigned_to?(agent),
|
|
67
|
+
message || "expected #{ticket.reference} to be held by #{agent.inspect}, was #{ticket.assignee.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Nobody is holding it.
|
|
71
|
+
def assert_unassigned(ticket, message = nil)
|
|
72
|
+
ticket.reload
|
|
73
|
+
assert_predicate ticket, :unassigned?,
|
|
74
|
+
message || "expected #{ticket.reference} to be unassigned, was #{ticket.assignee.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Assert the ticket's timeline holds an event of +kind+, optionally
|
|
78
|
+
# matching the actors in its payload.
|
|
79
|
+
def assert_ticket_event(ticket, kind, from: nil, to: nil, by: nil)
|
|
80
|
+
events = ticket.events.of_kind(kind).to_a
|
|
81
|
+
refute_empty events, "expected a #{kind} event on #{ticket.reference}, found " \
|
|
82
|
+
"#{ticket.events.map(&:kind).join(", ").presence || "none"}"
|
|
83
|
+
|
|
84
|
+
events = events.select { |event| event.payload["from"] == SupportDesk.actor_key(from) } if from
|
|
85
|
+
events = events.select { |event| event.payload["to"] == SupportDesk.actor_key(to) } if to
|
|
86
|
+
events = events.select { |event| event.actor == by } if by
|
|
87
|
+
|
|
88
|
+
refute_empty events, "expected a #{kind} event on #{ticket.reference} with " \
|
|
89
|
+
"#{{ from: from, to: to, by: by }.compact.inspect}"
|
|
90
|
+
events.first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Assert the case's timeline has NO event of this kind.
|
|
94
|
+
def refute_ticket_event(ticket, kind)
|
|
95
|
+
assert_empty ticket.events.of_kind(kind).to_a,
|
|
96
|
+
"expected no #{kind} event on #{ticket.reference}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- Configuration ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Run a block with different desk settings, then put them back:
|
|
102
|
+
#
|
|
103
|
+
# with_support_config(reply_policy: :assignee_only) { … }
|
|
104
|
+
# with_support_config(:billing, reply_within: 1.hour) { … }
|
|
105
|
+
def with_support_config(desk = :default, **overrides)
|
|
106
|
+
configuration = SupportDesk.config.desk(desk)
|
|
107
|
+
previous = overrides.keys.index_with { |key| configuration.read(key) }
|
|
108
|
+
had = overrides.keys.index_with { |key| configuration.own?(key) }
|
|
109
|
+
|
|
110
|
+
overrides.each { |key, value| configuration.public_send(:"#{key}=", value) }
|
|
111
|
+
yield
|
|
112
|
+
ensure
|
|
113
|
+
previous.each do |key, value|
|
|
114
|
+
had[key] ? configuration.public_send(:"#{key}=", value) : configuration.send(:reset_setting, key)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Capture the events the gem emits inside the block:
|
|
119
|
+
#
|
|
120
|
+
# events = capture_support_events(:ticket_closed) { ticket.close!(by: lucia) }
|
|
121
|
+
#
|
|
122
|
+
# The subscribers are removed again on the way out, block or raise, so a
|
|
123
|
+
# capture in one example can never fire in the next one.
|
|
124
|
+
def capture_support_events(*names)
|
|
125
|
+
captured = []
|
|
126
|
+
key = :"support_desk_capture_#{SecureRandom.hex(4)}"
|
|
127
|
+
names.each do |name|
|
|
128
|
+
SupportDesk.on(name, key: key) { |*args, **kwargs| captured << [ name, args, kwargs ] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
yield
|
|
132
|
+
captured
|
|
133
|
+
ensure
|
|
134
|
+
names.each { |name| SupportDesk.off(name, key) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The whole story of a case in one sequence: what was said and what was
|
|
5
|
+
# done, merged by time.
|
|
6
|
+
#
|
|
7
|
+
# ticket.timeline.each { |entry| … }
|
|
8
|
+
# ticket.timeline.print # in `rails c`
|
|
9
|
+
#
|
|
10
|
+
# Entries wrap either a Chats::Message or a SupportDesk::Event, and answer
|
|
11
|
+
# the same three questions — when, who, what.
|
|
12
|
+
class Timeline
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
# One moment in a case.
|
|
16
|
+
class Entry
|
|
17
|
+
attr_reader :at, :message, :event
|
|
18
|
+
|
|
19
|
+
# One moment: a message, or an event, and when it happened.
|
|
20
|
+
def initialize(at:, message: nil, event: nil)
|
|
21
|
+
@at = at
|
|
22
|
+
@message = message
|
|
23
|
+
@event = event
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Which of the two this moment is.
|
|
27
|
+
def message? = !message.nil?
|
|
28
|
+
def event? = !event.nil?
|
|
29
|
+
|
|
30
|
+
# :message, or the event's kind (:assigned, :closed, :note…).
|
|
31
|
+
def kind
|
|
32
|
+
message? ? :message : event.kind.to_sym
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Who is responsible for this moment: a message's author (the agent
|
|
36
|
+
# who signed it) or sender, or an event's actor.
|
|
37
|
+
def actor
|
|
38
|
+
return event.actor_or_system if event?
|
|
39
|
+
|
|
40
|
+
message.try(:author) || message.sender
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What was said, or what a note said. Nil for the rest.
|
|
44
|
+
def body
|
|
45
|
+
message? ? message.try(:visible_body) : event.note
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Time, kind and the first line of what was said — printable.
|
|
49
|
+
def to_s
|
|
50
|
+
"#{at&.iso8601} #{kind} #{body.to_s.truncate(60)}".strip
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The entry, in one line.
|
|
54
|
+
def inspect = "#<SupportDesk::Timeline::Entry #{to_s.inspect}>"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :ticket
|
|
58
|
+
|
|
59
|
+
# The timeline of one ticket.
|
|
60
|
+
def initialize(ticket)
|
|
61
|
+
@ticket = ticket
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Every moment, oldest first. Enumerable, so map/select/find work.
|
|
65
|
+
def each(&block)
|
|
66
|
+
return to_enum(:each) unless block
|
|
67
|
+
|
|
68
|
+
entries.each(&block)
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every moment, oldest first.
|
|
73
|
+
def entries
|
|
74
|
+
@entries ||= (message_entries + event_entries).sort_by { |entry| [ entry.at || Time.at(0), entry.kind.to_s ] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How many moments the case has had, and the latest one.
|
|
78
|
+
def size = entries.size
|
|
79
|
+
def last = entries.last
|
|
80
|
+
|
|
81
|
+
# Print the case to stdout, for consoles and scripts.
|
|
82
|
+
def print(io = $stdout)
|
|
83
|
+
each { |entry| io.puts(entry.to_s) }
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Which ticket, and how much has happened to it.
|
|
88
|
+
def inspect = "#<SupportDesk::Timeline #{ticket.reference} #{size} entries>"
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def message_entries
|
|
93
|
+
return [] if ticket.conversation.nil?
|
|
94
|
+
|
|
95
|
+
ticket.conversation.messages.visible.oldest_first.map do |message|
|
|
96
|
+
Entry.new(at: message.created_at, message: message)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def event_entries
|
|
101
|
+
ticket.events.chronological.map { |event| Entry.new(at: event.created_at, event: event) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|