support_desk 0.1.2 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +113 -12
  4. data/Rakefile +16 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +11 -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/app/views/support_desk/tickets/_write.html.erb +1 -0
  13. data/config/console_routes.rb +1 -1
  14. data/config/locales/support_desk.console.en.yml +26 -0
  15. data/config/locales/support_desk.console.es.yml +26 -0
  16. data/config/locales/support_desk.en.yml +3 -0
  17. data/config/locales/support_desk.es.yml +6 -0
  18. data/lib/generators/support_desk/console_generator.rb +1 -1
  19. data/lib/generators/support_desk/install_generator.rb +14 -0
  20. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  21. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  22. data/lib/generators/support_desk/templates/initializer.rb +30 -0
  23. data/lib/generators/support_desk/upgrade_generator.rb +56 -0
  24. data/lib/support_desk/configuration.rb +148 -2
  25. data/lib/support_desk/console.rb +358 -26
  26. data/lib/support_desk/console_engine.rb +2 -0
  27. data/lib/support_desk/console_routes.rb +20 -8
  28. data/lib/support_desk/context_card.rb +23 -0
  29. data/lib/support_desk/doctor.rb +51 -1
  30. data/lib/support_desk/errors.rb +5 -0
  31. data/lib/support_desk/macros.rb +28 -12
  32. data/lib/support_desk/models/assignment.rb +3 -1
  33. data/lib/support_desk/models/concerns/agent.rb +39 -4
  34. data/lib/support_desk/models/concerns/requester.rb +27 -12
  35. data/lib/support_desk/models/ticket.rb +509 -126
  36. data/lib/support_desk/summary.rb +1 -1
  37. data/lib/support_desk/test_helpers.rb +8 -3
  38. data/lib/support_desk/topic.rb +8 -29
  39. data/lib/support_desk/version.rb +1 -1
  40. data/lib/support_desk/wizard.rb +10 -27
  41. data/lib/support_desk.rb +45 -0
  42. data/lib/tasks/support_desk.rake +27 -0
  43. metadata +13 -2
@@ -46,7 +46,7 @@ module SupportDesk
46
46
  # this speaks the reader's language. Duration#inspect is English
47
47
  # whatever the locale, which left one untranslatable string in an
48
48
  # otherwise Spanish console.
49
- Wizard.humanize_duration(duration)
49
+ SupportDesk.humanize_duration(duration)
50
50
  end
51
51
 
52
52
  # The whole line.
@@ -6,6 +6,7 @@ module SupportDesk
6
6
  # include SupportDesk::TestHelpers
7
7
  #
8
8
  # ticket = open_support_ticket(for: users(:alice), about: rides(:sevilla), message: "No aparece")
9
+ # ticket = open_support_ticket(for: users(:alice), by: users(:lucia), message: "Vimos que…")
9
10
  # reply_as users(:lucia), ticket, "Lo miramos"
10
11
  # assert_awaiting_requester ticket
11
12
  # assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
@@ -15,10 +16,14 @@ module SupportDesk
15
16
  # The assertions read the same way the gem's own suite does, which is the
16
17
  # point: your acceptance tests and ours describe the same behaviour.
17
18
  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)
19
+ # Open a ticket the way a requester would — or, with `by:`, the way the
20
+ # desk does when it writes first. Through the public sugar in both cases,
21
+ # so this helper exercises what hosts actually type.
22
+ def open_support_ticket(message: "Necesito ayuda", about: nil, topic: nil, by: nil, **options)
20
23
  requester = options.fetch(:for) { raise ArgumentError, "open_support_ticket needs for: a requester" }
21
- requester.ask_support!(message, about: about, topic: topic)
24
+ return requester.ask_support!(message, about: about, topic: topic) if by.nil? || by == requester
25
+
26
+ by.open_support_conversation_with!(requester, message, about: about, topic: topic)
22
27
  end
23
28
 
24
29
  # Answer as an agent. Returns the Chats::Message.
@@ -192,9 +192,12 @@ module SupportDesk
192
192
 
193
193
  # The desk key tickets under this topic belong to.
194
194
  def desk_key
195
- (inherited_or_own(:desk) || :default).to_sym
195
+ desk_override || :default
196
196
  end
197
197
 
198
+ # nil means inherit the wizard's desk, not route to the default desk.
199
+ def desk_override = inherited_or_own(:desk)&.to_sym
200
+
198
201
  # Hidden from the wizard; the label still resolves for historic tickets.
199
202
  def retired? = !!inherited_or_own(:retired)
200
203
 
@@ -257,34 +260,10 @@ module SupportDesk
257
260
  def inspect = "#<SupportDesk::Topic::Unknown #{path}>"
258
261
  end
259
262
 
260
- # The ActiveModel attribute type behind `attribute :topic`: casts
261
- # Symbol | String | Topic on the way in, hands back a Topic on the way
262
- # out, and serializes to the path — so `where(topic: :ride)` works and a
263
- # view never sees a bare string.
264
- class Type < ActiveModel::Type::Value
265
- def type = :string
266
-
267
- def cast(value)
268
- case value
269
- when nil then nil
270
- when Topic then value
271
- else SupportDesk.find_topic(value.to_s)
272
- end
273
- end
274
-
275
- def serialize(value)
276
- case value
277
- when nil then nil
278
- when Topic then value.path
279
- else value.to_s
280
- end
281
- end
282
-
283
- def deserialize(value) = cast(value)
284
-
285
- def changed_in_place?(raw_old_value, new_value)
286
- raw_old_value != serialize(new_value)
287
- end
263
+ # Casting must not consult a global topic tree: the same path may mean
264
+ # different things on two desks. Ticket#topic resolves the stored path.
265
+ class Type < ActiveModel::Type::String
266
+ def cast(value) = value&.to_s
288
267
  end
289
268
  end
290
269
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SupportDesk
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -84,7 +84,7 @@ module SupportDesk
84
84
  # requester's desk, and a #desk that depended on the topic would ask the
85
85
  # tree for the topic to find out which tree to ask.
86
86
  def target_desk
87
- key = topic&.desk_key
87
+ key = topic&.desk_override
88
88
  return desk if key.nil? || key == desk.key
89
89
 
90
90
  SupportDesk.desk(key) || desk
@@ -186,20 +186,12 @@ module SupportDesk
186
186
  end
187
187
 
188
188
  # The promise as a Duration, for hosts that want to phrase it themselves.
189
- def promise_within = desk.config.reply_within
189
+ def promise_within = target_desk.config.reply_within
190
190
 
191
- # "1 day", "4 horas" — through ActionView's date helper so it speaks the
192
- # requester's language, falling back to Duration#inspect in the (rare)
193
- # app that has no ActionView.
194
- def self.humanize_duration(duration)
195
- return duration.inspect unless defined?(ActionView::Helpers::DateHelper)
196
-
197
- @duration_words ||= Object.new.extend(ActionView::Helpers::DateHelper)
198
- words = @duration_words.distance_of_time_in_words(duration.to_i).to_s
199
- # An app whose locale has no date translations (no rails-i18n) would
200
- # otherwise show "Translation missing" to a customer.
201
- words.start_with?("Translation missing") ? duration.inspect : words
202
- end
191
+ # "1 day", "4 horas" — the summary, the helper and an opening line's
192
+ # `%{reply_within}` all say it too, so it lives on SupportDesk now. Kept
193
+ # here as a delegation: it has been public since 0.1.
194
+ def self.humanize_duration(duration) = SupportDesk.humanize_duration(duration)
203
195
 
204
196
  # The open case this would land in, when the requester already has one
205
197
  # about this thing — so the wizard can say "you already have a
@@ -207,10 +199,7 @@ module SupportDesk
207
199
  def existing_ticket
208
200
  return nil if topic.nil? || topic.branch?
209
201
 
210
- key = Ticket.cardinality_key_for(requester: requester, subject: subject, topic: topic)
211
- return nil if key.start_with?("free:")
212
-
213
- Ticket.not_closed.find_by(requester: requester, desk: target_desk, cardinality_key: key)
202
+ Ticket.existing_for(requester: requester, desk: target_desk, subject: subject, topic: topic)
214
203
  end
215
204
 
216
205
  # Which records in +choices+ the requester already has an open case
@@ -270,15 +259,9 @@ module SupportDesk
270
259
  raise InvalidTransition, "the wizard is still on the #{step} step — pick one before submitting"
271
260
  end
272
261
 
273
- # `ask_support!` files under the REQUESTER's desk, which is right
274
- # until a topic says otherwise; then the redirect is spelled out.
275
- if target_desk == desk
276
- requester.ask_support!(message, about: subject, topic: topic.path, files: files)
277
- else
278
- Ticket.open!(requester: requester, message: message, about: subject, topic: topic.path,
279
- files: files, desk: target_desk,
280
- requester_role: requester.class.support_desk_requester_options[:as])
281
- end
262
+ Ticket.open!(requester: requester, message: message, about: subject, topic: topic.path,
263
+ files: files, desk: target_desk,
264
+ requester_role: requester.class.support_desk_requester_options[:as])
282
265
  end
283
266
 
284
267
  # A signed token for the currently chosen subject, to round-trip through
data/lib/support_desk.rb CHANGED
@@ -158,6 +158,17 @@ module SupportDesk
158
158
  # Every class that has declared itself able to answer.
159
159
  def agent_class_names = @agent_class_names ||= Set.new
160
160
 
161
+ # The registered classes themselves, for the places that have to restrict
162
+ # a lookup to them — the console's GlobalID allow-lists, where a raw
163
+ # token is an identifier and never an authorization to call `find` on
164
+ # whatever class name it names.
165
+ #
166
+ # Names that no longer resolve are dropped rather than raised on: the
167
+ # registries store names so they survive reloads, and a class that went
168
+ # away is simply not one of the classes people can be looked up as.
169
+ def requester_classes = resolve_all(requester_class_names)
170
+ def supportable_classes = resolve_all(supportable_class_names)
171
+
161
172
  # Whether +klass+ (a Class, an instance, or a class name) is supportable.
162
173
  def supportable_class?(klass) = registered?(supportable_class_names, klass)
163
174
  # Whether +klass+ asks for support / answers it. Ancestor-aware, so an
@@ -165,6 +176,20 @@ module SupportDesk
165
176
  def requester_class?(klass) = registered?(requester_class_names, klass)
166
177
  def agent_class?(klass) = registered?(agent_class_names, klass)
167
178
 
179
+ # --- Macro conditions -----------------------------------------------------------
180
+
181
+ # Whether +record+ passes an `if:` condition from a macro — nil is always
182
+ # yes. Both `has_support_tickets if:` (may this person ask for help, and
183
+ # be written to) and `acts_as_support_agent if:` (may this person answer)
184
+ # are read through here, so the two conditions can never drift into two
185
+ # meanings of the same option.
186
+ def eligible?(record, condition)
187
+ return true if condition.nil?
188
+ return !!record.public_send(condition) if condition.is_a?(Symbol)
189
+
190
+ !!condition.call(record)
191
+ end
192
+
168
193
  # --- The chats seam -------------------------------------------------------------
169
194
 
170
195
  # Subscribe the gem's own `:message_created` listener, which is what
@@ -257,6 +282,22 @@ module SupportDesk
257
282
  ]
258
283
  end
259
284
 
285
+ # A duration in the reader's own language ("1 día", "4 hours") — the
286
+ # answer promise in the wizard's line, the wait in a console summary, and
287
+ # the `%{reply_within}` an opening line can interpolate.
288
+ #
289
+ # Duration#inspect is English whatever the locale, which is what left one
290
+ # untranslatable string in an otherwise Spanish console.
291
+ def humanize_duration(duration)
292
+ return duration.inspect unless defined?(ActionView::Helpers::DateHelper)
293
+
294
+ @duration_words ||= Object.new.extend(ActionView::Helpers::DateHelper)
295
+ words = @duration_words.distance_of_time_in_words(duration.to_i).to_s
296
+ # An app whose locale has no date translations (no rails-i18n) would
297
+ # otherwise show "Translation missing" to a customer.
298
+ words.start_with?("Translation missing") ? duration.inspect : words
299
+ end
300
+
260
301
  # A stable, URL-safe key for an actor (agent, requester, desk), used in
261
302
  # cache keys and event payloads. GlobalID params already encode class +
262
303
  # id, so two classes can never collide.
@@ -274,6 +315,10 @@ module SupportDesk
274
315
  klass
275
316
  end
276
317
 
318
+ def resolve_all(registry)
319
+ registry.filter_map { |name| name.safe_constantize }
320
+ end
321
+
277
322
  def registered?(registry, klass)
278
323
  klass = klass.class unless klass.is_a?(Class) || klass.is_a?(String)
279
324
  name = klass.is_a?(String) ? klass : klass.name
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :support_desk do
4
+ desc "Point every case with no provenance at its requester (the 0.1 → 0.2 catch-up)"
5
+ task backfill_opened_by: :environment do
6
+ # The same UPDATE the 0.2.0 migration runs, for the handover window: a
7
+ # 0.1 process still serving traffic writes NULL provenance after the
8
+ # migration's own backfill, so the catch-up is run once more when those
9
+ # processes are gone.
10
+ #
11
+ # Idempotent, and safe to run at any time, for one reason: 0.2 opens
12
+ # every case as a record (automation openers are refused — see
13
+ # docs/12-open-questions.md Q17), so a NULL `opened_by` can only be a row
14
+ # 0.1 wrote. The day automation lands, NULL becomes legitimate provenance
15
+ # and this task stops being safe — that is part of the work Q17 names.
16
+ relation = SupportDesk::Ticket.where(opened_by_id: nil)
17
+ pending = relation.count
18
+
19
+ if pending.zero?
20
+ puts "[support_desk] every case already says who opened it."
21
+ next
22
+ end
23
+
24
+ updated = relation.update_all("opened_by_type = requester_type, opened_by_id = requester_id")
25
+ puts "[support_desk] #{updated} case(s) now point at their requester."
26
+ end
27
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: support_desk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-16 00:00:00.000000000 Z
10
+ date: 2026-09-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -56,6 +56,9 @@ dependencies:
56
56
  - - "~>"
57
57
  - !ruby/object:Gem::Version
58
58
  version: '0.3'
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 0.3.2
59
62
  type: :runtime
60
63
  prerelease: false
61
64
  version_requirements: !ruby/object:Gem::Requirement
@@ -63,6 +66,9 @@ dependencies:
63
66
  - - "~>"
64
67
  - !ruby/object:Gem::Version
65
68
  version: '0.3'
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 0.3.2
66
72
  - !ruby/object:Gem::Dependency
67
73
  name: globalid
68
74
  requirement: !ruby/object:Gem::Requirement
@@ -138,11 +144,13 @@ files:
138
144
  - app/views/support_desk/console/tickets/_context_card.html.erb
139
145
  - app/views/support_desk/console/tickets/_message.html.erb
140
146
  - app/views/support_desk/console/tickets/_nav_badge.html.erb
147
+ - app/views/support_desk/console/tickets/_new_conversation_form.html.erb
141
148
  - app/views/support_desk/console/tickets/_tabs.html.erb
142
149
  - app/views/support_desk/console/tickets/_ticket_row.html.erb
143
150
  - app/views/support_desk/console/tickets/_timeline.html.erb
144
151
  - app/views/support_desk/console/tickets/_transcript.html.erb
145
152
  - app/views/support_desk/console/tickets/index.html.erb
153
+ - app/views/support_desk/console/tickets/new.html.erb
146
154
  - app/views/support_desk/console/tickets/show.html.erb
147
155
  - app/views/support_desk/tickets/_context_card.html.erb
148
156
  - app/views/support_desk/tickets/_door.html.erb
@@ -166,10 +174,12 @@ files:
166
174
  - gemfiles/rails_8.1.gemfile
167
175
  - lib/generators/support_desk/console_generator.rb
168
176
  - lib/generators/support_desk/install_generator.rb
177
+ - lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb
169
178
  - lib/generators/support_desk/templates/console/controller.rb.erb
170
179
  - lib/generators/support_desk/templates/console/resource.rb.erb
171
180
  - lib/generators/support_desk/templates/create_support_desk_tables.rb.erb
172
181
  - lib/generators/support_desk/templates/initializer.rb
182
+ - lib/generators/support_desk/upgrade_generator.rb
173
183
  - lib/generators/support_desk/views_generator.rb
174
184
  - lib/support_desk.rb
175
185
  - lib/support_desk/configuration.rb
@@ -199,6 +209,7 @@ files:
199
209
  - lib/support_desk/topic_tree.rb
200
210
  - lib/support_desk/version.rb
201
211
  - lib/support_desk/wizard.rb
212
+ - lib/tasks/support_desk.rake
202
213
  homepage: https://github.com/rameerez/support_desk
203
214
  licenses:
204
215
  - MIT