support_desk 0.3.0 → 0.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +206 -0
  3. data/README.md +227 -16
  4. data/app/controllers/support_desk/application_controller.rb +5 -2
  5. data/app/controllers/support_desk/tickets_controller.rb +68 -3
  6. data/app/helpers/support_desk/engine_helper.rb +10 -10
  7. data/app/helpers/support_desk/thread_helper.rb +29 -0
  8. data/app/views/support_desk/tickets/show.html.erb +8 -0
  9. data/config/locales/support_desk.en.yml +28 -0
  10. data/config/locales/support_desk.es.yml +28 -0
  11. data/lib/generators/support_desk/install_generator.rb +9 -3
  12. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  13. data/lib/generators/support_desk/templates/initializer.rb +21 -1
  14. data/lib/generators/support_desk/upgrade_generator.rb +15 -7
  15. data/lib/support_desk/brief.rb +1 -1
  16. data/lib/support_desk/configuration.rb +100 -19
  17. data/lib/support_desk/conversation_access.rb +42 -0
  18. data/lib/support_desk/doctor.rb +30 -3
  19. data/lib/support_desk/engine.rb +13 -1
  20. data/lib/support_desk/message_signatures.rb +20 -0
  21. data/lib/support_desk/models/assistant.rb +43 -8
  22. data/lib/support_desk/models/draft.rb +9 -0
  23. data/lib/support_desk/models/message_registration.rb +12 -0
  24. data/lib/support_desk/models/ticket/assistance.rb +254 -30
  25. data/lib/support_desk/models/ticket.rb +172 -67
  26. data/lib/support_desk/transcript.rb +9 -12
  27. data/lib/support_desk/version.rb +1 -1
  28. data/lib/support_desk/wizard.rb +1 -5
  29. data/lib/support_desk.rb +82 -11
  30. data/lib/tasks/support_desk.rake +12 -1
  31. metadata +20 -11
data/lib/support_desk.rb CHANGED
@@ -124,7 +124,11 @@ module SupportDesk
124
124
  "`config.assistant #{key.inspect} do |assistant| … end`"
125
125
  end
126
126
 
127
- assistants[key] ||= Assistant.for(key)
127
+ # Resolving her is also when what she is CALLED is written down, so a
128
+ # message she signed still says the same thing after somebody takes
129
+ # her out of the initializer (R8). It writes only when configuration
130
+ # and the row disagree.
131
+ (assistants[key] ||= Assistant.for(key)).snapshot_disclosure!
128
132
  end
129
133
 
130
134
  # Every assistant record this process has resolved, keyed by key.
@@ -146,6 +150,31 @@ module SupportDesk
146
150
  record.respond_to?(:support_agent_kind) && record.support_agent_kind == :ai
147
151
  end
148
152
 
153
+ # Give back every seat an assistant can no longer sit in — she was
154
+ # switched off, her configuration was removed, or her policy no longer
155
+ # lets her hold a case — and ask for a person on each of them.
156
+ #
157
+ # Invalid-assignee recovery, NOT silence detection: it reads the seats
158
+ # that exist rather than the assistants a running process happens to
159
+ # have configured, so a `deactivate!`, a flag flipped off and a topic
160
+ # cap tightened are all covered, with no `responds_within` and no
161
+ # overdue clock anywhere in it (R6). `release_silent_assistants!` runs
162
+ # it first; `rake support_desk:reclaim_assistant_seats` runs it alone.
163
+ #
164
+ # Returns how many seats it reclaimed.
165
+ def reclaim_assistant_seats!
166
+ return 0 unless Assistant.table_exists?
167
+
168
+ reclaimed = 0
169
+ Ticket.open.held_by_assistants.find_each do |ticket|
170
+ reclaimed += 1 if ticket.reclaim_assistant_seat!
171
+ rescue StandardError => e
172
+ report_error(e, context: { hook: :reclaim_assistant_seats, ticket: ticket.id })
173
+ end
174
+ logger&.info("[support_desk] reclaimed #{reclaimed} stranded assistant seat(s)") if reclaimed.positive?
175
+ reclaimed
176
+ end
177
+
149
178
  # Release every assistant who has sat on a case longer than her
150
179
  # `responds_within` without answering — and ask for a person on it.
151
180
  #
@@ -154,18 +183,19 @@ module SupportDesk
154
183
  # Run it every minute (`rake support_desk:release_silent_assistants`).
155
184
  # Returns how many cases it moved.
156
185
  def release_silent_assistants!
157
- moved = 0
186
+ # A seat nobody can sit in any more is not a silence problem, and it
187
+ # must not need a `responds_within` to be noticed (R6).
188
+ moved = reclaim_assistant_seats!
158
189
  config.assistants.each_key do |key|
159
190
  agent = assistant(key)
160
191
  window = agent&.responds_within
161
192
  next if window.nil?
162
193
 
163
194
  Ticket.open.assigned_to(agent).awaiting_reply.waiting_over(window).find_each do |ticket|
164
- next if ticket.human_required?
165
-
166
- ticket.escalate!(by: :system, reason: "assistant_silent",
167
- summary: "no answer in #{humanize_duration(window)}")
168
- moved += 1 if ticket.human_required?
195
+ # Conditional, under the case's own lock: every predicate above was
196
+ # true when this row was SELECTED, and a person may have answered
197
+ # it since (R5).
198
+ moved += 1 if ticket.escalate_if_still_silent!(agent, window)
169
199
  rescue StandardError => e
170
200
  report_error(e, context: { hook: :release_silent_assistants, ticket: ticket.id })
171
201
  end
@@ -186,6 +216,19 @@ module SupportDesk
186
216
 
187
217
  config.desks.each_key do |key|
188
218
  agent = desk(key)&.assistant
219
+ # Repair before deciding. A requester message whose registration was
220
+ # lost after commit leaves the clocks describing a case that no longer
221
+ # exists — and `assistant_idle_since` reads those clocks, so the case
222
+ # it most needs to find is the one it cannot see. Folding the message
223
+ # in COMMITS on its own and emits the turn it produces, so a dead
224
+ # process followed by nothing but this task still ends in an
225
+ # actionable turn (R3).
226
+ Ticket.for_desk(key).with_unregistered_requester_messages.find_each do |ticket|
227
+ emitted += 1 if ticket.reconcile_and_commit!.positive?
228
+ rescue StandardError => e
229
+ report_error(e, context: { hook: :redispatch_assistant_turns, ticket: ticket.id })
230
+ end
231
+
189
232
  next if agent.nil?
190
233
 
191
234
  Ticket.open.for_desk(key).assistant_idle_since(older_than.ago).find_each do |ticket|
@@ -359,8 +402,10 @@ module SupportDesk
359
402
  #
360
403
  # Both surfaces are ordinary PUSHED screens (`context: "default"`), never
361
404
  # modals: the wizard is three real URLs and a modal would break the back
362
- # gesture between them. The thread itself is deliberately absent — it is
363
- # a chats conversation, and it stays under the host's chats rule.
405
+ # gesture between them. The thread itself is absent under the default
406
+ # `thread_page = :chats` — it is a chats conversation, and it stays under
407
+ # the host's chats rule. Under `:support` it is this engine's ticket page,
408
+ # and a third rule pushes it like the list.
364
409
  #
365
410
  # +mount+ defaults to wherever the engine is mounted and +title+ to the
366
411
  # desk's name.
@@ -386,8 +431,16 @@ module SupportDesk
386
431
  properties: { context: "default", title: title, pull_to_refresh_enabled: false },
387
432
  comment: "The wizard: every step is a real URL, so it pushes and the back gesture works. " \
388
433
  "Pull to refresh is off — it would throw away what the requester has typed."
389
- }
390
- ]
434
+ },
435
+ (if config.support_thread_page?
436
+ {
437
+ patterns: [ "^#{prefix}/tickets/[^/?]+(?:\\?.*)?$" ],
438
+ properties: { context: "default", title: title, pull_to_refresh_enabled: false },
439
+ comment: "A case, read on the support page (thread_page = :support). Pull to refresh is off " \
440
+ "for the same reason as the wizard: the composer at its foot holds what is typed."
441
+ }
442
+ end)
443
+ ].compact
391
444
  end
392
445
 
393
446
  # A duration in the reader's own language ("1 día", "4 hours") — the
@@ -406,6 +459,24 @@ module SupportDesk
406
459
  words.start_with?("Translation missing") ? duration.inspect : words
407
460
  end
408
461
 
462
+ # The answer promise, in the unit the desk made it in: `reply_within =
463
+ # 72.hours` reads "72 hours", `3.days` reads "3 days", `24.hours` reads
464
+ # "24 hours". A wait is approximate and `humanize_duration` rounds it
465
+ # ("about 8 hours", "3 days"); a promise is a number somebody chose, and
466
+ # rounding it says something they did not.
467
+ #
468
+ # The words are `support_desk.durations.<unit>`, pluralized by count. A
469
+ # duration of more than one unit (1.day + 2.hours), a fractional one, or
470
+ # a unit with no words in this locale is said the way a wait is.
471
+ def humanize_promise(duration)
472
+ parts = duration.respond_to?(:parts) ? duration.parts : {}
473
+ unit, count = parts.first if parts.size == 1
474
+ key = :"support_desk.durations.#{unit}"
475
+ return humanize_duration(duration) unless count.is_a?(Integer) && I18n.exists?(key)
476
+
477
+ I18n.t(key, count: count)
478
+ end
479
+
409
480
  # A stable, URL-safe key for an actor (agent, requester, desk), used in
410
481
  # cache keys and event payloads. GlobalID params already encode class +
411
482
  # id, so two classes can never collide.
@@ -38,6 +38,17 @@ namespace :support_desk do
38
38
  puts "[support_desk] #{moved} case(s) handed to a person."
39
39
  end
40
40
 
41
+ desc "Give back every seat an assistant can no longer sit in (run every minute)"
42
+ task reclaim_assistant_seats: :environment do
43
+ # Invalid-assignee recovery: she was switched off, her configuration was
44
+ # taken away, or her policy no longer lets her hold a case. It needs no
45
+ # `responds_within` and no overdue clock — `release_silent_assistants`
46
+ # runs it first, and this is the same work on its own.
47
+ reclaimed = SupportDesk.reclaim_assistant_seats!
48
+
49
+ puts "[support_desk] #{reclaimed} stranded seat(s) given back to people."
50
+ end
51
+
41
52
  desc "Re-emit the turn for cases nobody answered (OLDER_THAN=60 seconds; run every 5 minutes)"
42
53
  task redispatch_assistant_turns: :environment do
43
54
  # At-least-once, and that is safe: the turn is consumed by the first
@@ -48,7 +59,7 @@ namespace :support_desk do
48
59
  puts "[support_desk] #{emitted} turn(s) re-emitted (idle for more than #{older_than.inspect})."
49
60
  end
50
61
 
51
- desc "What the assistants are doing right now (read-only)"
62
+ desc "Report assistant status without changing tickets"
52
63
  task assistant_status: :environment do
53
64
  if SupportDesk.config.assistants.empty?
54
65
  puts "[support_desk] no assistant is configured."
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_desk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 2026-09-19 00:00:00.000000000 Z
11
+ date: 2026-10-18 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activerecord
@@ -53,22 +54,22 @@ dependencies:
53
54
  name: chats
54
55
  requirement: !ruby/object:Gem::Requirement
55
56
  requirements:
56
- - - "~>"
57
- - !ruby/object:Gem::Version
58
- version: '0.3'
59
57
  - - ">="
60
58
  - !ruby/object:Gem::Version
61
- version: 0.3.2
59
+ version: 0.5.1
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.0'
62
63
  type: :runtime
63
64
  prerelease: false
64
65
  version_requirements: !ruby/object:Gem::Requirement
65
66
  requirements:
66
- - - "~>"
67
- - !ruby/object:Gem::Version
68
- version: '0.3'
69
67
  - - ">="
70
68
  - !ruby/object:Gem::Version
71
- version: 0.3.2
69
+ version: 0.5.1
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '1.0'
72
73
  - !ruby/object:Gem::Dependency
73
74
  name: globalid
74
75
  requirement: !ruby/object:Gem::Requirement
@@ -143,6 +144,7 @@ files:
143
144
  - app/controllers/support_desk/console/tickets_controller.rb
144
145
  - app/controllers/support_desk/tickets_controller.rb
145
146
  - app/helpers/support_desk/engine_helper.rb
147
+ - app/helpers/support_desk/thread_helper.rb
146
148
  - app/views/chats/slots/_inbox_top.html.erb
147
149
  - app/views/chats/slots/_locked_composer.html.erb
148
150
  - app/views/support_desk/console/tickets/_actions.html.erb
@@ -171,6 +173,7 @@ files:
171
173
  - app/views/support_desk/tickets/index.html.erb
172
174
  - app/views/support_desk/tickets/new.html.erb
173
175
  - app/views/support_desk/tickets/rate_limited.html.erb
176
+ - app/views/support_desk/tickets/show.html.erb
174
177
  - config/console_routes.rb
175
178
  - config/locales/support_desk.console.en.yml
176
179
  - config/locales/support_desk.console.es.yml
@@ -191,6 +194,7 @@ files:
191
194
  - lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb
192
195
  - lib/generators/support_desk/templates/console/controller.rb.erb
193
196
  - lib/generators/support_desk/templates/console/resource.rb.erb
197
+ - lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb
194
198
  - lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
195
199
  - lib/generators/support_desk/templates/initializer.rb
196
200
  - lib/generators/support_desk/upgrade_generator.rb
@@ -203,12 +207,14 @@ files:
203
207
  - lib/support_desk/console_engine.rb
204
208
  - lib/support_desk/console_routes.rb
205
209
  - lib/support_desk/context_card.rb
210
+ - lib/support_desk/conversation_access.rb
206
211
  - lib/support_desk/current.rb
207
212
  - lib/support_desk/doctor.rb
208
213
  - lib/support_desk/engine.rb
209
214
  - lib/support_desk/errors.rb
210
215
  - lib/support_desk/events.rb
211
216
  - lib/support_desk/macros.rb
217
+ - lib/support_desk/message_signatures.rb
212
218
  - lib/support_desk/models/application_record.rb
213
219
  - lib/support_desk/models/assignment.rb
214
220
  - lib/support_desk/models/assistant.rb
@@ -218,6 +224,7 @@ files:
218
224
  - lib/support_desk/models/desk.rb
219
225
  - lib/support_desk/models/draft.rb
220
226
  - lib/support_desk/models/event.rb
227
+ - lib/support_desk/models/message_registration.rb
221
228
  - lib/support_desk/models/ticket.rb
222
229
  - lib/support_desk/models/ticket/assistance.rb
223
230
  - lib/support_desk/outcome.rb
@@ -241,6 +248,7 @@ metadata:
241
248
  bug_tracker_uri: https://github.com/rameerez/support_desk/issues
242
249
  documentation_uri: https://github.com/rameerez/support_desk#readme
243
250
  rubygems_mfa_required: 'true'
251
+ post_install_message:
244
252
  rdoc_options: []
245
253
  require_paths:
246
254
  - lib
@@ -255,7 +263,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
255
263
  - !ruby/object:Gem::Version
256
264
  version: '0'
257
265
  requirements: []
258
- rubygems_version: 3.6.2
266
+ rubygems_version: 3.4.10
267
+ signing_key:
259
268
  specification_version: 4
260
269
  summary: 'Customer support for your Rails app: tickets that are conversations, worked
261
270
  by your team and by AI agents with real guardrails'