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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +105 -10
  4. data/Rakefile +16 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +5 -3
  6. data/app/helpers/support_desk/engine_helper.rb +7 -1
  7. data/app/views/support_desk/console/tickets/_context_card.html.erb +5 -0
  8. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  9. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +8 -0
  10. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  11. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  12. data/config/console_routes.rb +1 -1
  13. data/config/locales/support_desk.console.en.yml +26 -0
  14. data/config/locales/support_desk.console.es.yml +26 -0
  15. data/config/locales/support_desk.en.yml +3 -0
  16. data/config/locales/support_desk.es.yml +6 -0
  17. data/lib/generators/support_desk/console_generator.rb +1 -1
  18. data/lib/generators/support_desk/install_generator.rb +14 -0
  19. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  20. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  21. data/lib/generators/support_desk/templates/initializer.rb +30 -0
  22. data/lib/generators/support_desk/upgrade_generator.rb +56 -0
  23. data/lib/support_desk/configuration.rb +148 -2
  24. data/lib/support_desk/console.rb +347 -25
  25. data/lib/support_desk/console_engine.rb +2 -0
  26. data/lib/support_desk/console_routes.rb +20 -8
  27. data/lib/support_desk/context_card.rb +23 -0
  28. data/lib/support_desk/doctor.rb +51 -1
  29. data/lib/support_desk/errors.rb +5 -0
  30. data/lib/support_desk/macros.rb +28 -12
  31. data/lib/support_desk/models/assignment.rb +3 -1
  32. data/lib/support_desk/models/concerns/agent.rb +39 -4
  33. data/lib/support_desk/models/concerns/requester.rb +27 -12
  34. data/lib/support_desk/models/ticket.rb +464 -100
  35. data/lib/support_desk/summary.rb +1 -1
  36. data/lib/support_desk/test_helpers.rb +8 -3
  37. data/lib/support_desk/version.rb +1 -1
  38. data/lib/support_desk/wizard.rb +4 -12
  39. data/lib/support_desk.rb +45 -0
  40. data/lib/tasks/support_desk.rake +27 -0
  41. metadata +7 -2
@@ -20,11 +20,15 @@ module SupportDesk
20
20
  module Macros
21
21
  # The requester side. `desk:` names which desk this model writes to;
22
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)
23
+ # console can tell two kinds of requester apart; `if:` is the per-record
24
+ # check for "may this person ask for help — and be written to — right
25
+ # now" (`if: :kept?` for a host with soft-deleted accounts).
26
+ def has_support_tickets(desk: :default, as: nil, **options)
27
+ condition = Macros.condition!(options, macro: "has_support_tickets", known: ":if, :desk, :as")
28
+
25
29
  include SupportDesk::Requester
26
30
 
27
- self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s }.freeze
31
+ self.support_desk_requester_options = { desk: desk.to_sym, as: as&.to_s, if: condition }.freeze
28
32
  SupportDesk.register_requester(self)
29
33
  end
30
34
 
@@ -48,26 +52,38 @@ module SupportDesk
48
52
 
49
53
  # The agent side: who may answer. `if:` is the per-record eligibility
50
54
  # 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.
55
+ # One verb is added — `open_support_conversation_with!`, the only agent
56
+ # action that has no ticket yet. Everything else keeps the ticket as the
57
+ # subject of the sentence (`ticket.assign!(to: lucia)`).
53
58
  def acts_as_support_agent(kind: :human, **options)
59
+ condition = Macros.condition!(options, macro: "acts_as_support_agent", known: ":if, :kind")
60
+
61
+ include SupportDesk::Agent
62
+
63
+ self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
64
+ SupportDesk.register_agent(self)
65
+ end
66
+
67
+ # The `if:` both macros take: a method name or a callable, and the only
68
+ # option either of them accepts beyond its own keywords. Written once
69
+ # because "may this person answer" and "may this person ask" are the same
70
+ # question asked of a different side, and two copies of it would be two
71
+ # places to fix the day one grows a third shape.
72
+ def self.condition!(options, macro:, known:)
54
73
  unknown = options.keys - [ :if ]
55
74
  if unknown.any?
56
75
  raise ConfigurationError,
57
- "unknown acts_as_support_agent option#{"s" if unknown.size > 1} " \
58
- "#{unknown.map(&:inspect).join(", ")} — known options are :if, :kind"
76
+ "unknown #{macro} option#{"s" if unknown.size > 1} " \
77
+ "#{unknown.map(&:inspect).join(", ")} — known options are #{known}"
59
78
  end
60
79
 
61
80
  condition = options[:if]
62
81
  unless condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
63
82
  raise ConfigurationError,
64
- "acts_as_support_agent if: must be a method name or a callable, got #{condition.inspect}"
83
+ "#{macro} if: must be a method name or a callable, got #{condition.inspect}"
65
84
  end
66
85
 
67
- include SupportDesk::Agent
68
-
69
- self.support_desk_agent_options = { if: condition, kind: kind.to_sym }.freeze
70
- SupportDesk.register_agent(self)
86
+ condition
71
87
  end
72
88
  end
73
89
  end
@@ -11,7 +11,9 @@ module SupportDesk
11
11
  class Assignment < ApplicationRecord
12
12
  self.table_name = "support_desk_assignments"
13
13
 
14
- REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened].freeze
14
+ # `opened`: the agent who wrote first holds the case. It is a seat nobody
15
+ # was told about — see Ticket.open!(by:) — not an assignment somebody made.
16
+ REASONS = %w[taken assigned handed_off routed drop_in_takeover escalated reopened opened].freeze
15
17
  RELEASE_REASONS = %w[handed_off released shift_end closed escalated].freeze
16
18
 
17
19
  belongs_to :ticket, class_name: "SupportDesk::Ticket", inverse_of: :assignments
@@ -38,11 +38,46 @@ module SupportDesk
38
38
  # Whether this record may answer tickets right now — the `if:` condition
39
39
  # from the macro, honoured.
40
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)
41
+ SupportDesk.eligible?(self, self.class.support_desk_agent_options[:if])
42
+ end
43
+
44
+ # Write first, as the desk — the mirror of Requester#ask_support!.
45
+ #
46
+ # lucia.open_support_conversation_with!(alice, "Vimos que tu retirada rebotó", about: withdrawal)
47
+ # lucia.open_support_conversation_with!(alice, "Tu DNI no se lee bien", topic: :verification)
48
+ #
49
+ # Not `message!`: that is a personal chat from Lucía. This speaks as the
50
+ # desk, signs the message with her name, seats her on the case, and lands
51
+ # in Alice's inbox as "Soporte". If Alice already has this conversation
52
+ # open, the message joins it as an ordinary reply, under the desk's reply
53
+ # policy — which can leave the case with whoever already holds it.
54
+ # Returns the SupportDesk::Ticket.
55
+ #
56
+ # Raises NotARequester (nobody to write to), NotAllowed (including an
57
+ # agent aiming at themselves: an agent who needs help asks for it),
58
+ # NotSupportable, UnknownTopic.
59
+ def open_support_conversation_with!(requester, message, about: nil, topic: nil, files: [], via: :in_app,
60
+ desk: nil, request: nil)
61
+ SupportDesk::Ticket.ensure_requester!(requester)
62
+
63
+ if SupportDesk::Ticket.same_actor?(requester, self)
64
+ raise SupportDesk::NotAllowed,
65
+ "#{self.class}##{id} can't open a support conversation with themselves — an agent who needs " \
66
+ "help asks for it (ask_support!), and the case would be theirs to answer"
67
+ end
44
68
 
45
- !!condition.call(self)
69
+ SupportDesk::Ticket.open!(
70
+ requester: requester,
71
+ by: self,
72
+ message: message,
73
+ about: about,
74
+ topic: topic,
75
+ files: files,
76
+ via: via,
77
+ desk: desk || requester.support_desk,
78
+ requester_role: requester.class.support_desk_requester_options[:as],
79
+ request: request
80
+ )
46
81
  end
47
82
 
48
83
  # :human or :ai. Bots disclose themselves through this (04).
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- # Included by `has_support_tickets`. Adds exactly four methods the whole
5
- # requester side of the gem:
4
+ # Included by `has_support_tickets`. The whole requester side of the gem:
6
5
  #
7
6
  # alice.ask_support!("El viaje no aparece verificado", about: ride)
8
7
  # alice.support_tickets.open.about(ride)
8
+ # alice.support_requester?
9
+ # alice.support_desk
9
10
  # alice.awaiting_support_reply?
10
11
  # alice.unread_support_count
11
12
  module Requester
@@ -13,7 +14,7 @@ module SupportDesk
13
14
 
14
15
  included do
15
16
  class_attribute :support_desk_requester_options, instance_writer: false,
16
- default: { desk: :default, as: nil }.freeze
17
+ default: { desk: :default, as: nil, if: nil }.freeze
17
18
 
18
19
  has_many :support_tickets,
19
20
  class_name: "SupportDesk::Ticket",
@@ -26,6 +27,26 @@ module SupportDesk
26
27
  def support_desk_key = support_desk_requester_options[:desk]
27
28
  end
28
29
 
30
+ # Whether this record may ask for help — or be written to — right now:
31
+ # the `if:` condition from `has_support_tickets`, honoured. A closed
32
+ # account answers false and can do neither, while its history stays
33
+ # readable (see Ticket#chat_locked?).
34
+ def support_requester?
35
+ SupportDesk.eligible?(self, self.class.support_desk_requester_options[:if])
36
+ end
37
+
38
+ # The desk this requester writes to (`has_support_tickets desk:`), as a
39
+ # record. Raises ConfigurationError naming the fix when that desk isn't
40
+ # configured — its tickets would otherwise silently land on the default
41
+ # desk.
42
+ def support_desk
43
+ key = self.class.support_desk_key
44
+ SupportDesk.desk(key) ||
45
+ raise(SupportDesk::ConfigurationError,
46
+ "#{self.class} writes to desk #{key.inspect}, which isn't configured — " \
47
+ "its tickets would silently land on the default desk")
48
+ end
49
+
29
50
  # Open a ticket and say the first thing. Returns the SupportDesk::Ticket
30
51
  # — the existing open one when this requester already has a ticket about
31
52
  # the same record (or, for free-form tickets, the same topic).
@@ -33,15 +54,9 @@ module SupportDesk
33
54
  # alice.ask_support!("No me han pagado", about: withdrawal)
34
55
  # alice.ask_support!("¿Cómo borro mi cuenta?", topic: :account)
35
56
  #
36
- # Raises NotSupportable, UnknownTopic, NotAllowed, RateLimited,
37
- # TooManyOpenTickets.
57
+ # Raises NotARequester, NotSupportable, UnknownTopic, NotAllowed,
58
+ # RateLimited, TooManyOpenTickets.
38
59
  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
60
  SupportDesk::Ticket.open!(
46
61
  requester: self,
47
62
  message: message,
@@ -49,7 +64,7 @@ module SupportDesk
49
64
  topic: topic,
50
65
  files: files,
51
66
  via: via,
52
- desk: desk,
67
+ desk: support_desk,
53
68
  requester_role: self.class.support_desk_requester_options[:as]
54
69
  )
55
70
  end