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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The three host-facing macros. The engine extends `ActiveRecord::Base`
|
|
5
|
+
# with this module, so any model can declare:
|
|
6
|
+
#
|
|
7
|
+
# class User < ApplicationRecord
|
|
8
|
+
# acts_as_messager # chats
|
|
9
|
+
# has_support_tickets # can ask for help
|
|
10
|
+
# acts_as_support_agent if: :admin? # can answer
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# class Ride < ApplicationRecord
|
|
14
|
+
# supportable topic: :ride # can be asked about
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Each macro includes the matching concern — all the behaviour lives in
|
|
18
|
+
# SupportDesk::Requester / Supportable / Agent, so it's discoverable,
|
|
19
|
+
# testable, and `include`-able directly when a host prefers that style.
|
|
20
|
+
module Macros
|
|
21
|
+
# The requester side. `desk:` names which desk this model writes to;
|
|
22
|
+
# `as:` records a role on every ticket ("driver", "passenger") so the
|
|
23
|
+
# console can tell two kinds of requester apart.
|
|
24
|
+
def has_support_tickets(desk: :default, as: nil)
|
|
25
|
+
include SupportDesk::Requester
|
|
26
|
+
|
|
27
|
+
self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s }.freeze
|
|
28
|
+
SupportDesk.register_requester(self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Makes a domain record something people can ask about: a ride, an
|
|
32
|
+
# order, a withdrawal. `topic:` is the topic a ticket opened from this
|
|
33
|
+
# record's door lands on; `candidates:` overrides the "which one?"
|
|
34
|
+
# picker; `one_open_ticket: false` lets a requester hold several open
|
|
35
|
+
# tickets about the same record.
|
|
36
|
+
def supportable(topic:, candidates: nil, one_open_ticket: true)
|
|
37
|
+
if candidates && !candidates.respond_to?(:call)
|
|
38
|
+
raise ConfigurationError, "supportable candidates: must respond to #call, got #{candidates.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
include SupportDesk::Supportable
|
|
42
|
+
|
|
43
|
+
self.support_desk_supportable_options = {
|
|
44
|
+
topic: topic.to_s, candidates: candidates, one_open_ticket: !!one_open_ticket
|
|
45
|
+
}.freeze
|
|
46
|
+
SupportDesk.register_supportable(self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The agent side: who may answer. `if:` is the per-record eligibility
|
|
50
|
+
# check (a Symbol method name or a callable); `kind:` is :human or :ai.
|
|
51
|
+
# No verbs are added — the ticket is the subject of every sentence
|
|
52
|
+
# (`ticket.assign!(to: lucia)`), never the agent.
|
|
53
|
+
def acts_as_support_agent(kind: :human, **options)
|
|
54
|
+
unknown = options.keys - [ :if ]
|
|
55
|
+
if unknown.any?
|
|
56
|
+
raise ConfigurationError,
|
|
57
|
+
"unknown acts_as_support_agent option#{"s" if unknown.size > 1} " \
|
|
58
|
+
"#{unknown.map(&:inspect).join(", ")} — known options are :if, :kind"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
condition = options[:if]
|
|
62
|
+
unless condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
|
|
63
|
+
raise ConfigurationError,
|
|
64
|
+
"acts_as_support_agent if: must be a method name or a callable, got #{condition.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
include SupportDesk::Agent
|
|
68
|
+
|
|
69
|
+
self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
|
|
70
|
+
SupportDesk.register_agent(self)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Abstract base for every support_desk model. Kept separate from the
|
|
5
|
+
# host's ApplicationRecord on purpose: the gem's models must not inherit
|
|
6
|
+
# host callbacks or scopes, and the host must be able to swap its own base
|
|
7
|
+
# class without touching ours.
|
|
8
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Who held a ticket, when, and why they stopped. Assignment is a history,
|
|
5
|
+
# not a column: hand-offs, drop-in take-overs, shift releases and "time
|
|
6
|
+
# per agent" are unanswerable from a single `assignee_id`.
|
|
7
|
+
#
|
|
8
|
+
# `ticket.assignee` is the denormalised pointer to the one row with
|
|
9
|
+
# `released_at IS NULL`; the two are written together inside one
|
|
10
|
+
# transaction, and `SupportDesk.doctor` checks they still agree.
|
|
11
|
+
class Assignment < ApplicationRecord
|
|
12
|
+
self.table_name = "support_desk_assignments"
|
|
13
|
+
|
|
14
|
+
REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened].freeze
|
|
15
|
+
RELEASE_REASONS = %w[handed_off released shift_end closed escalated].freeze
|
|
16
|
+
|
|
17
|
+
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :assignments
|
|
18
|
+
belongs_to :agent, polymorphic: true
|
|
19
|
+
belongs_to :assigned_by, polymorphic: true, optional: true
|
|
20
|
+
|
|
21
|
+
validates :reason, inclusion: { in: REASONS }
|
|
22
|
+
validates :release_reason, inclusion: { in: RELEASE_REASONS }, allow_nil: true
|
|
23
|
+
|
|
24
|
+
scope :open, -> { where(released_at: nil) }
|
|
25
|
+
scope :released, -> { where.not(released_at: nil) }
|
|
26
|
+
scope :chronological, -> { order(:assigned_at, :id) }
|
|
27
|
+
scope :for_agent, ->(agent) { where(agent: agent) }
|
|
28
|
+
|
|
29
|
+
# Hand the ticket to +agent+: closes whoever held it and opens a new
|
|
30
|
+
# row. The caller holds the ticket's row lock (every transition does),
|
|
31
|
+
# which is what makes "at most one open assignment" true rather than
|
|
32
|
+
# hopeful — the partial unique index is the belt underneath it.
|
|
33
|
+
def self.open!(ticket:, agent:, by: nil, reason: :assigned, note: nil, release_reason: nil)
|
|
34
|
+
open.where(ticket: ticket).each do |assignment|
|
|
35
|
+
assignment.release!(reason: release_reason || default_release_reason(reason))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
create!(
|
|
39
|
+
ticket: ticket,
|
|
40
|
+
agent: agent,
|
|
41
|
+
assigned_by: by.is_a?(Symbol) ? nil : by,
|
|
42
|
+
reason: reason.to_s,
|
|
43
|
+
note: note,
|
|
44
|
+
assigned_at: Time.current
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What the previous holder's row records when a new one opens.
|
|
49
|
+
def self.default_release_reason(reason)
|
|
50
|
+
case reason.to_s
|
|
51
|
+
when "handed_off" then "handed_off"
|
|
52
|
+
when "escalated" then "escalated"
|
|
53
|
+
else "released"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether this is the seat somebody is sitting in right now.
|
|
58
|
+
def open? = released_at.nil?
|
|
59
|
+
def released? = !open?
|
|
60
|
+
|
|
61
|
+
# Close this row. Idempotent: releasing a released assignment changes
|
|
62
|
+
# nothing.
|
|
63
|
+
def release!(reason: :released)
|
|
64
|
+
return self unless open?
|
|
65
|
+
|
|
66
|
+
update!(released_at: Time.current, release_reason: reason.to_s)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How long this agent held the ticket (so far).
|
|
71
|
+
def held_for
|
|
72
|
+
return nil if assigned_at.nil?
|
|
73
|
+
|
|
74
|
+
ActiveSupport::Duration.build(((released_at || Time.current) - assigned_at).to_i)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The seat, in one line.
|
|
78
|
+
def inspect
|
|
79
|
+
"#<SupportDesk::Assignment ticket=#{ticket_id} agent=#{agent_type}##{agent_id} " \
|
|
80
|
+
"#{reason}#{" released:#{release_reason}" if released?}>"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Included by `acts_as_support_agent`. Agents are never chats participants
|
|
5
|
+
# — the desk sends, the agent *authors* — so a model becomes an agent with
|
|
6
|
+
# no messaging setup at all.
|
|
7
|
+
#
|
|
8
|
+
# class User < ApplicationRecord
|
|
9
|
+
# acts_as_support_agent if: :admin?
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# lucia.support_agent? # => true
|
|
13
|
+
# lucia.support_queue.mine # => relation
|
|
14
|
+
module Agent
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
class_attribute :support_desk_agent_options, instance_writer: false,
|
|
19
|
+
default: { if: nil, kind: :human }.freeze
|
|
20
|
+
|
|
21
|
+
has_many :support_assignments,
|
|
22
|
+
class_name: "SupportDesk::Assignment",
|
|
23
|
+
as: :agent,
|
|
24
|
+
inverse_of: :agent,
|
|
25
|
+
dependent: :nullify
|
|
26
|
+
|
|
27
|
+
has_many :support_tickets_assigned,
|
|
28
|
+
class_name: "SupportDesk::Ticket",
|
|
29
|
+
as: :assignee,
|
|
30
|
+
inverse_of: :assignee,
|
|
31
|
+
dependent: :nullify
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class_methods do
|
|
35
|
+
def support_agent_class? = true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether this record may answer tickets right now — the `if:` condition
|
|
39
|
+
# from the macro, honoured.
|
|
40
|
+
def support_agent?
|
|
41
|
+
condition = self.class.support_desk_agent_options[:if]
|
|
42
|
+
return true if condition.nil?
|
|
43
|
+
return !!public_send(condition) if condition.is_a?(Symbol)
|
|
44
|
+
|
|
45
|
+
!!condition.call(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# :human or :ai. Bots disclose themselves through this (04).
|
|
49
|
+
def support_agent_kind
|
|
50
|
+
self.class.support_desk_agent_options[:kind]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The signature requesters see under an answer.
|
|
54
|
+
def support_agent_name
|
|
55
|
+
%i[public_name display_name name].each do |method|
|
|
56
|
+
value = try(method)
|
|
57
|
+
return value.to_s if value.present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
"#{self.class.model_name.human} #{id}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Anything `image_tag` accepts, or nil.
|
|
64
|
+
def support_agent_avatar = try(:avatar)
|
|
65
|
+
|
|
66
|
+
# Whether the desk should route or notify this agent right now. True by
|
|
67
|
+
# default: hosts back it with a schedule, a presence bit, or the duty
|
|
68
|
+
# table that ships in 0.3.
|
|
69
|
+
def on_duty? = true
|
|
70
|
+
|
|
71
|
+
# How many open tickets this agent can hold; nil is unlimited.
|
|
72
|
+
def support_capacity = nil
|
|
73
|
+
|
|
74
|
+
# This agent's view of the desk: tabs, counts, badge, next ticket.
|
|
75
|
+
def support_queue(desk: nil)
|
|
76
|
+
SupportDesk::Queue.for(self, desk: desk)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Included by `has_support_tickets`. Adds exactly four methods — the whole
|
|
5
|
+
# requester side of the gem:
|
|
6
|
+
#
|
|
7
|
+
# alice.ask_support!("El viaje no aparece verificado", about: ride)
|
|
8
|
+
# alice.support_tickets.open.about(ride)
|
|
9
|
+
# alice.awaiting_support_reply?
|
|
10
|
+
# alice.unread_support_count
|
|
11
|
+
module Requester
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
class_attribute :support_desk_requester_options, instance_writer: false,
|
|
16
|
+
default: { desk: :default, as: nil }.freeze
|
|
17
|
+
|
|
18
|
+
has_many :support_tickets,
|
|
19
|
+
class_name: "SupportDesk::Ticket",
|
|
20
|
+
as: :requester,
|
|
21
|
+
inverse_of: :requester,
|
|
22
|
+
dependent: :restrict_with_error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class_methods do
|
|
26
|
+
def support_desk_key = support_desk_requester_options[:desk]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Open a ticket and say the first thing. Returns the SupportDesk::Ticket
|
|
30
|
+
# — the existing open one when this requester already has a ticket about
|
|
31
|
+
# the same record (or, for free-form tickets, the same topic).
|
|
32
|
+
#
|
|
33
|
+
# alice.ask_support!("No me han pagado", about: withdrawal)
|
|
34
|
+
# alice.ask_support!("¿Cómo borro mi cuenta?", topic: :account)
|
|
35
|
+
#
|
|
36
|
+
# Raises NotSupportable, UnknownTopic, NotAllowed, RateLimited,
|
|
37
|
+
# TooManyOpenTickets.
|
|
38
|
+
def ask_support!(message, about: nil, topic: nil, files: [], via: :in_app)
|
|
39
|
+
key = self.class.support_desk_requester_options[:desk]
|
|
40
|
+
desk = SupportDesk.desk(key) ||
|
|
41
|
+
raise(SupportDesk::ConfigurationError,
|
|
42
|
+
"#{self.class} writes to desk #{key.inspect}, which isn't configured — " \
|
|
43
|
+
"its tickets would silently land on the default desk")
|
|
44
|
+
|
|
45
|
+
SupportDesk::Ticket.open!(
|
|
46
|
+
requester: self,
|
|
47
|
+
message: message,
|
|
48
|
+
about: about,
|
|
49
|
+
topic: topic,
|
|
50
|
+
files: files,
|
|
51
|
+
via: via,
|
|
52
|
+
desk: desk,
|
|
53
|
+
requester_role: self.class.support_desk_requester_options[:as]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# True when any of this requester's open tickets is waiting on the desk.
|
|
58
|
+
def awaiting_support_reply?
|
|
59
|
+
support_tickets.not_closed.awaiting_reply.exists?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Unread messages across every support conversation this requester has,
|
|
63
|
+
# counted against the chats read horizon — the number for a nav badge.
|
|
64
|
+
def unread_support_count
|
|
65
|
+
conversation_ids = support_tickets.where.not(conversation_id: nil).distinct.pluck(:conversation_id)
|
|
66
|
+
return 0 if conversation_ids.empty?
|
|
67
|
+
|
|
68
|
+
Chats::Conversation.unread_counts_for(self, conversation_ids).values.sum
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# Included by `supportable`. Every method has a working default; override
|
|
5
|
+
# the ones that matter to you:
|
|
6
|
+
#
|
|
7
|
+
# class Ride < ApplicationRecord
|
|
8
|
+
# supportable topic: :ride
|
|
9
|
+
#
|
|
10
|
+
# def support_label = "#{origin} → #{destination} · #{departs_on.to_fs(:short)}"
|
|
11
|
+
# def support_status = status_pill&.label
|
|
12
|
+
# def support_context = { "Conductor" => driver.public_name, "Plazas" => seats }
|
|
13
|
+
# def support_url = Rails.application.routes.url_helpers.madmin_ride_path(self)
|
|
14
|
+
# def supportable_by?(requester) = participants.exists?(user: requester)
|
|
15
|
+
# end
|
|
16
|
+
module Supportable
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
class_attribute :support_desk_supportable_options, instance_writer: false,
|
|
21
|
+
default: { topic: nil, candidates: nil,
|
|
22
|
+
one_open_ticket: true }.freeze
|
|
23
|
+
|
|
24
|
+
has_many :support_tickets,
|
|
25
|
+
class_name: "SupportDesk::Ticket",
|
|
26
|
+
as: :subject,
|
|
27
|
+
inverse_of: :subject,
|
|
28
|
+
dependent: :nullify
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class_methods do
|
|
32
|
+
# True — this class is supportable. (The predicate exists on the class
|
|
33
|
+
# AND the instance so config validation, doors and pickers can ask
|
|
34
|
+
# either one.)
|
|
35
|
+
def supportable? = true
|
|
36
|
+
|
|
37
|
+
# The records the "which one?" picker offers +requester+: the
|
|
38
|
+
# `candidates:` proc when given, else the requester's own association
|
|
39
|
+
# by this model's plural name, else none.
|
|
40
|
+
def support_candidates_for(requester)
|
|
41
|
+
proc = support_desk_supportable_options[:candidates]
|
|
42
|
+
return proc.call(requester) if proc.respond_to?(:call)
|
|
43
|
+
|
|
44
|
+
association = model_name.plural
|
|
45
|
+
return requester.public_send(association) if requester.respond_to?(association)
|
|
46
|
+
|
|
47
|
+
none
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether a requester may hold only one open ticket about a given
|
|
51
|
+
# record of this class.
|
|
52
|
+
def one_open_support_ticket? = support_desk_supportable_options[:one_open_ticket]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# True — this record can be asked about.
|
|
56
|
+
def supportable? = true
|
|
57
|
+
|
|
58
|
+
# The topic a ticket opened about this record lands on.
|
|
59
|
+
def support_topic
|
|
60
|
+
self.class.support_desk_supportable_options[:topic]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# How this record is named in ticket labels, context cards and the
|
|
64
|
+
# opening notice.
|
|
65
|
+
def support_label
|
|
66
|
+
"#{self.class.model_name.human} #{id}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A short status pill rendered under the label, in the picker and the
|
|
70
|
+
# console. nil renders nothing.
|
|
71
|
+
def support_status = nil
|
|
72
|
+
|
|
73
|
+
# Key/value pairs an agent sees in the console's context card. Rendered
|
|
74
|
+
# to agents only — you decide what belongs there.
|
|
75
|
+
def support_context = {}
|
|
76
|
+
|
|
77
|
+
# Where an agent can open this record in your admin. nil renders no link.
|
|
78
|
+
def support_url = nil
|
|
79
|
+
|
|
80
|
+
# May +requester+ open a ticket about this record? The default is the
|
|
81
|
+
# obvious ownership check; override it for anything else.
|
|
82
|
+
def supportable_by?(requester)
|
|
83
|
+
return false if requester.nil?
|
|
84
|
+
|
|
85
|
+
respond_to?(:user) && user == requester
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The desk: who answers, as far as the requester is concerned.
|
|
5
|
+
#
|
|
6
|
+
# A desk is a chats messager — a headless one. It has no notifications of
|
|
7
|
+
# its own (agents are notified through the host's own fan-out, never by
|
|
8
|
+
# this gem) and can't be blocked or reported as a person, because it isn't
|
|
9
|
+
# one. Its conversations collapse into a single grouped inbox row, so
|
|
10
|
+
# somebody with four open tickets sees "Soporte" once, not four times.
|
|
11
|
+
#
|
|
12
|
+
# SupportDesk.desk # the :default desk, memoised
|
|
13
|
+
# SupportDesk.desk(:billing) # another one
|
|
14
|
+
#
|
|
15
|
+
# Rows are created lazily and never INSERT-first: a desk is read on every
|
|
16
|
+
# page and written once in its life.
|
|
17
|
+
class Desk < ApplicationRecord
|
|
18
|
+
self.table_name = "support_desk_desks"
|
|
19
|
+
|
|
20
|
+
acts_as_messager notifications: false,
|
|
21
|
+
blockable: false,
|
|
22
|
+
inbox: :grouped,
|
|
23
|
+
group_path: ->(_viewer) { SupportDesk.root_path }
|
|
24
|
+
|
|
25
|
+
has_many :tickets,
|
|
26
|
+
class_name: "SupportDesk::Ticket",
|
|
27
|
+
inverse_of: :desk,
|
|
28
|
+
dependent: :restrict_with_error
|
|
29
|
+
|
|
30
|
+
# Ruby-side default so settings is always a Hash even on MySQL, where a
|
|
31
|
+
# JSON column can't carry a DB default.
|
|
32
|
+
attribute :settings, default: -> { {} }
|
|
33
|
+
|
|
34
|
+
validates :key, presence: true
|
|
35
|
+
|
|
36
|
+
# The desk for +key+, found or created. Never INSERT-first: `find_by`
|
|
37
|
+
# answers from the index on every call but the very first.
|
|
38
|
+
#
|
|
39
|
+
# `SupportDesk.desk` then memoises the record FOR THE LIFE OF THE
|
|
40
|
+
# PROCESS, so a `settings` change written by another process (a console,
|
|
41
|
+
# another web worker) is not picked up until this one boots again or
|
|
42
|
+
# somebody calls `SupportDesk.reset_desks!`. That is the trade the
|
|
43
|
+
# performance requirement asks for — a desk is read on every page and
|
|
44
|
+
# written once in its life — and it is why configuration, not
|
|
45
|
+
# `settings`, is the place to put anything that has to change together
|
|
46
|
+
# everywhere.
|
|
47
|
+
def self.for(key)
|
|
48
|
+
key = key.to_s
|
|
49
|
+
find_by(key: key) || create_or_find_by!(key: key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# This desk's slice of the configuration.
|
|
53
|
+
def config
|
|
54
|
+
SupportDesk.config.desk(key)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What requesters see as the counterpart. Configuration wins; the
|
|
58
|
+
# `settings` column is the runtime fallback for hosts that let staff
|
|
59
|
+
# rename a desk from a console.
|
|
60
|
+
def name
|
|
61
|
+
config.read(:name) || settings["name"].presence || key.to_s.humanize
|
|
62
|
+
end
|
|
63
|
+
alias display_name name
|
|
64
|
+
|
|
65
|
+
# Anything `image_tag` accepts, or nil. A callable is passed the desk.
|
|
66
|
+
def avatar
|
|
67
|
+
value = config.read(:avatar) || settings["avatar"].presence
|
|
68
|
+
value.respond_to?(:call) ? value.call(self) : value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The address the email channel answers from (0.2).
|
|
72
|
+
def email
|
|
73
|
+
config.read(:email) || settings["email"].presence
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The agent pool, resolved from `config.agents`.
|
|
77
|
+
def agents
|
|
78
|
+
config.agent_pool
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The pool, minus anyone who says they're off duty.
|
|
82
|
+
def on_duty_agents
|
|
83
|
+
agents.select { |agent| !agent.respond_to?(:on_duty?) || agent.on_duty? }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether +record+ may answer this desk's tickets.
|
|
87
|
+
def agent?(record)
|
|
88
|
+
return false if record.nil? || record.is_a?(Symbol)
|
|
89
|
+
|
|
90
|
+
record.respond_to?(:support_agent?) && record.support_agent?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A desk prints as its name — it's a counterpart, not a row.
|
|
94
|
+
def to_s = name
|
|
95
|
+
|
|
96
|
+
# The desk, in one line.
|
|
97
|
+
def inspect
|
|
98
|
+
"#<SupportDesk::Desk #{key} #{name.inspect}>"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SupportDesk
|
|
4
|
+
# The append-only timeline of everything that is not a message: who took
|
|
5
|
+
# the ticket, who handed it off, who closed it, what an agent noted.
|
|
6
|
+
#
|
|
7
|
+
# Rows are written inside the same transaction as the transition they
|
|
8
|
+
# describe — exactly one row per transition — and are read-only forever
|
|
9
|
+
# after. Hosts with tamper-evident logs mirror them by subscribing to
|
|
10
|
+
# `:ticket_transitioned` rather than by writing here.
|
|
11
|
+
class Event < ApplicationRecord
|
|
12
|
+
self.table_name = "support_desk_events"
|
|
13
|
+
|
|
14
|
+
KINDS = %w[
|
|
15
|
+
opened assigned handed_off released drop_in closed reopened topic_changed subject_attached note
|
|
16
|
+
snoozed woken escalated channel_added email_bounced email_unverified rated tagged
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :events
|
|
20
|
+
belongs_to :actor, polymorphic: true, optional: true
|
|
21
|
+
|
|
22
|
+
attribute :payload, default: -> { {} }
|
|
23
|
+
|
|
24
|
+
validates :kind, inclusion: { in: KINDS }
|
|
25
|
+
|
|
26
|
+
scope :chronological, -> { order(:created_at, :id) }
|
|
27
|
+
scope :of_kind, ->(*kinds) { where(kind: kinds.flatten.map(&:to_s)) }
|
|
28
|
+
scope :notes, -> { where(kind: "note") }
|
|
29
|
+
# Everything a requester may see in an export: their own case's story,
|
|
30
|
+
# never the desk's internal reasoning.
|
|
31
|
+
scope :requester_visible, -> { where.not(kind: %w[note drop_in]) }
|
|
32
|
+
|
|
33
|
+
# Write one event. `actor` may be a record or a Symbol (`:system`,
|
|
34
|
+
# `:routing`) — symbols are kept in the payload, since there is no row
|
|
35
|
+
# to point at.
|
|
36
|
+
def self.record!(ticket:, kind:, actor: nil, payload: {})
|
|
37
|
+
create!(
|
|
38
|
+
ticket: ticket,
|
|
39
|
+
kind: kind.to_s,
|
|
40
|
+
actor: actor.is_a?(Symbol) || actor.nil? ? nil : actor,
|
|
41
|
+
payload: payload.merge(actor.is_a?(Symbol) ? { "by" => actor.to_s } : {})
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Append-only, enforced the way ActiveRecord can enforce it: a loaded
|
|
46
|
+
# event refuses `update!`, `update_column` and friends.
|
|
47
|
+
#
|
|
48
|
+
# It is NOT tamper-proofing. `update_all` and `delete_all` never
|
|
49
|
+
# instantiate a record, so they bypass this exactly as they bypass every
|
|
50
|
+
# other model-level rule, and anything with database access can rewrite
|
|
51
|
+
# a row regardless. A host that needs tamper EVIDENCE mirrors
|
|
52
|
+
# `:ticket_transitioned` into its own hash-chained log; this guarantees
|
|
53
|
+
# that the gem, and code using the gem's models, only ever appends.
|
|
54
|
+
def readonly?
|
|
55
|
+
persisted?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Who acted, as something you can print: the actor record, or the
|
|
59
|
+
# symbol kept in the payload.
|
|
60
|
+
def actor_or_system
|
|
61
|
+
actor || payload["by"]&.to_sym
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The text of an internal note, for kind "note".
|
|
65
|
+
def note = payload["note"]
|
|
66
|
+
|
|
67
|
+
# The event, in one line.
|
|
68
|
+
def inspect
|
|
69
|
+
"#<SupportDesk::Event #{kind} ticket=#{ticket_id} #{created_at&.iso8601}>"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|