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,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