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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/.simplecov +53 -0
  4. data/Appraisals +18 -0
  5. data/CHANGELOG.md +135 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +469 -0
  8. data/Rakefile +55 -0
  9. data/app/assets/stylesheets/support_desk.css +242 -0
  10. data/app/controllers/support_desk/application_controller.rb +92 -0
  11. data/app/controllers/support_desk/console/application_controller.rb +18 -0
  12. data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
  13. data/app/controllers/support_desk/tickets_controller.rb +119 -0
  14. data/app/helpers/support_desk/engine_helper.rb +196 -0
  15. data/app/views/chats/slots/_inbox_top.html.erb +31 -0
  16. data/app/views/chats/slots/_locked_composer.html.erb +18 -0
  17. data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
  18. data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
  19. data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
  20. data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
  21. data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
  22. data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
  23. data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
  24. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
  25. data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
  26. data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
  27. data/app/views/support_desk/console/tickets/index.html.erb +37 -0
  28. data/app/views/support_desk/console/tickets/show.html.erb +45 -0
  29. data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
  30. data/app/views/support_desk/tickets/_door.html.erb +12 -0
  31. data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
  32. data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
  33. data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
  34. data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
  35. data/app/views/support_desk/tickets/_write.html.erb +56 -0
  36. data/app/views/support_desk/tickets/index.html.erb +53 -0
  37. data/app/views/support_desk/tickets/new.html.erb +14 -0
  38. data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
  39. data/config/console_routes.rb +16 -0
  40. data/config/locales/support_desk.console.en.yml +93 -0
  41. data/config/locales/support_desk.console.es.yml +93 -0
  42. data/config/locales/support_desk.en.yml +79 -0
  43. data/config/locales/support_desk.es.yml +84 -0
  44. data/config/routes.rb +24 -0
  45. data/context7.json +4 -0
  46. data/gemfiles/rails_7.2.gemfile +34 -0
  47. data/gemfiles/rails_8.0.gemfile +34 -0
  48. data/gemfiles/rails_8.1.gemfile +34 -0
  49. data/lib/generators/support_desk/console_generator.rb +94 -0
  50. data/lib/generators/support_desk/install_generator.rb +86 -0
  51. data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
  52. data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
  53. data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
  54. data/lib/generators/support_desk/templates/initializer.rb +186 -0
  55. data/lib/generators/support_desk/views_generator.rb +50 -0
  56. data/lib/support_desk/configuration.rb +675 -0
  57. data/lib/support_desk/console.rb +487 -0
  58. data/lib/support_desk/console_engine.rb +63 -0
  59. data/lib/support_desk/console_routes.rb +107 -0
  60. data/lib/support_desk/context_card.rb +90 -0
  61. data/lib/support_desk/current.rb +26 -0
  62. data/lib/support_desk/doctor.rb +220 -0
  63. data/lib/support_desk/engine.rb +141 -0
  64. data/lib/support_desk/errors.rb +49 -0
  65. data/lib/support_desk/events.rb +122 -0
  66. data/lib/support_desk/macros.rb +73 -0
  67. data/lib/support_desk/models/application_record.rb +11 -0
  68. data/lib/support_desk/models/assignment.rb +83 -0
  69. data/lib/support_desk/models/concerns/agent.rb +79 -0
  70. data/lib/support_desk/models/concerns/requester.rb +71 -0
  71. data/lib/support_desk/models/concerns/supportable.rb +88 -0
  72. data/lib/support_desk/models/desk.rb +101 -0
  73. data/lib/support_desk/models/event.rb +72 -0
  74. data/lib/support_desk/models/ticket.rb +1124 -0
  75. data/lib/support_desk/queue.rb +171 -0
  76. data/lib/support_desk/summary.rb +70 -0
  77. data/lib/support_desk/test_helpers.rb +137 -0
  78. data/lib/support_desk/timeline.rb +104 -0
  79. data/lib/support_desk/topic.rb +290 -0
  80. data/lib/support_desk/topic_tree.rb +214 -0
  81. data/lib/support_desk/version.rb +5 -0
  82. data/lib/support_desk/wizard.rb +392 -0
  83. data/lib/support_desk.rb +288 -0
  84. 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