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
@@ -8,6 +8,11 @@ es:
8
8
  closed_notice: "Esta conversación está cerrada. Abre una nueva si necesitas algo más."
9
9
  reopened_notice: "Hemos vuelto a abrir esta conversación."
10
10
  promise: "Normalmente respondemos en menos de %{time}"
11
+ # La línea con la que se abre un hilo que ha empezado el equipo: quien
12
+ # recibe un mensaje de un sitio al que nunca ha escrito necesita saber
13
+ # qué es esto.
14
+ opened_by_support: "%{desk} ha abierto esta conversación contigo sobre «%{label}». Puedes responder aquí mismo."
15
+ unavailable_notice: "Esta cuenta ya no puede enviar ni recibir mensajes. La conversación se queda aquí para consultarla."
11
16
  notifications:
12
17
  # Generic on purpose: una pantalla de bloqueo no debe contar de qué va
13
18
  # el caso de nadie. El detalle va en el cuerpo.
@@ -45,6 +50,7 @@ es:
45
50
  state:
46
51
  awaiting_reply: "Estamos con ello"
47
52
  answered: "Te hemos contestado"
53
+ opened_by_support: "Te hemos escrito"
48
54
  closed: "Cerrada"
49
55
  # The ways in, from anywhere in the host app.
50
56
  doors:
@@ -60,7 +60,7 @@ module SupportDesk
60
60
  say " 1. One route line:"
61
61
  say " # config/routes.rb"
62
62
  say " namespace :#{target} do"
63
- say " resources :support_tickets, only: %i[index show], concerns: :support_console"
63
+ say " resources :support_tickets, only: %i[index show new], concerns: :support_console"
64
64
  say " end"
65
65
 
66
66
  say " 2. A badge in your admin nav:"
@@ -27,6 +27,15 @@ module SupportDesk
27
27
  File.join(db_migrate_path, "create_support_desk_tables.rb")
28
28
  end
29
29
 
30
+ # Who opened each case. A SEPARATE migration, copied from the same
31
+ # template `support_desk:upgrade` hands an existing install, so one
32
+ # file owns those two columns and their index wherever they came from
33
+ # — and a fresh install's `down` removes exactly what its `up` added.
34
+ def create_opened_by_migration
35
+ migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
36
+ File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
37
+ end
38
+
30
39
  # The annotated initializer — every setting the gem has, with what it
31
40
  # means and what it defaults to.
32
41
  def create_initializer
@@ -72,6 +81,11 @@ module SupportDesk
72
81
  say " # app/mailboxes/application_mailbox.rb"
73
82
  say " # routing(/^support@/i => :support_desk)"
74
83
 
84
+ say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
85
+ say " only the migrations the new version needs (0.2.0: who opened the case), and"
86
+ say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
87
+ say " support traffic, drain old web/workers, backfill, then serve only 0.2."
88
+
75
89
  say "\nCheck your work any time with SupportDesk.doctor.print"
76
90
  say "You now have support tickets that are real conversations. 🚀\n", :green
77
91
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # support_desk 0.2.0 — WHO OPENED THE CASE.
4
+ #
5
+ # `opened_by` is a record, exactly like `closed_by`: the requester when they
6
+ # asked, the agent when the desk wrote first. It is nullable for one reason
7
+ # only — rows written by 0.1.x have no provenance to state — and this
8
+ # migration backfills every one of them to its requester, because 0.1 had no
9
+ # way to open a case as anybody else.
10
+ #
11
+ # The new column's type is read from the tickets table's OWN `requester_id`
12
+ # rather than from today's generator setting: both columns point at the same
13
+ # host records, so an app that changed `primary_key_type` after installing
14
+ # would otherwise be handed a bigint column for uuid ids.
15
+ class AddOpenedByToSupportDeskTickets < ActiveRecord::Migration<%= migration_version %>
16
+ INDEX_NAME = "index_support_desk_tickets_on_opened_by"
17
+
18
+ def up
19
+ ensure_columns_are_ours!
20
+
21
+ add_reference :support_desk_tickets, :opened_by, polymorphic: true, null: true,
22
+ type: opened_by_id_type, index: false
23
+ add_index :support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME
24
+
25
+ # SQL, and deliberately not the model: a backfill that loaded today's
26
+ # Ticket would run this release's validations, callbacks and events over
27
+ # last release's rows, in a migration that must only move two columns.
28
+ execute(<<~SQL.squish)
29
+ UPDATE support_desk_tickets
30
+ SET opened_by_type = requester_type,
31
+ opened_by_id = requester_id
32
+ WHERE opened_by_id IS NULL
33
+ SQL
34
+ end
35
+
36
+ # Exactly what `up` added, and nothing else. Note that this DESTROYS
37
+ # provenance: every case the desk opened stops being distinguishable from
38
+ # one the requester opened. It is here for a failed deploy of the schema
39
+ # alone, never as the way back from a code rollback.
40
+ def down
41
+ remove_index :support_desk_tickets, name: INDEX_NAME if
42
+ index_exists?(:support_desk_tickets, [ :opened_by_type, :opened_by_id ], name: INDEX_NAME)
43
+
44
+ remove_column :support_desk_tickets, :opened_by_type if
45
+ column_exists?(:support_desk_tickets, :opened_by_type)
46
+ remove_column :support_desk_tickets, :opened_by_id if
47
+ column_exists?(:support_desk_tickets, :opened_by_id)
48
+ end
49
+
50
+ private
51
+
52
+ # A column we did not add is a schema we cannot reason about: its type may
53
+ # not match, it may hold something else's data, and `down` would drop it.
54
+ # Say so with the way out rather than skipping the backfill and the index
55
+ # in silence.
56
+ def ensure_columns_are_ours!
57
+ existing = %i[opened_by_type opened_by_id].select { |name| column_exists?(:support_desk_tickets, name) }
58
+ return if existing.empty?
59
+
60
+ raise ActiveRecord::MigrationError,
61
+ "support_desk_tickets already has #{existing.join(" and ")}. This migration owns those two columns " \
62
+ "and #{INDEX_NAME}, so it won't overwrite them. Verify both column types, the index and existing " \
63
+ "provenance against this migration before reconciling its version in schema_migrations. " \
64
+ "db:migrate:up runs this method again; it does not mark it as already run. Preserve existing data."
65
+ end
66
+
67
+ # The storage this table already uses for a host record's id — uuid,
68
+ # bigint, integer. `opened_by_id` points at the same records as
69
+ # `requester_id`, so it has to be stored the same way.
70
+ def opened_by_id_type
71
+ column = connection.columns(:support_desk_tickets).find { |candidate| candidate.name == "requester_id" }
72
+ unless column
73
+ raise ActiveRecord::MigrationError,
74
+ "support_desk_tickets has no requester_id column — run support_desk's install migration first."
75
+ end
76
+
77
+ column.sql_type
78
+ end
79
+ end
@@ -16,7 +16,7 @@ module <%= module_name %>
16
16
  # The routes come from the `:support_console` routing concern:
17
17
  #
18
18
  # namespace :<%= target %> do
19
- # resources :support_tickets, only: %i[index show], concerns: :support_console
19
+ # resources :support_tickets, only: %i[index show new], concerns: :support_console
20
20
  # end
21
21
  #
22
22
  # Both `index` and `show` are yours — the gem deliberately doesn't own the
@@ -19,6 +19,23 @@ SupportDesk.configure do |config|
19
19
  #
20
20
  # config.agents { User.where(admin: true) }
21
21
 
22
+ # Who may ask, and who may answer, record by record — the macros' `if:`:
23
+ #
24
+ # class User < ApplicationRecord
25
+ # has_support_tickets if: :kept? # a closed account can neither
26
+ # acts_as_support_agent if: :admin? # ask nor be written to
27
+ # end
28
+ #
29
+ # `has_support_tickets if:` is a WRITE rule, not a screen rule: the thread
30
+ # and its history stay readable when it turns false.
31
+
32
+ # How the console finds the person an agent types in "Write to someone":
33
+ # email, phone, handle — your call. Given the typed string, return a
34
+ # requester record or nil. Without it the console accepts only a GlobalID
35
+ # from one of your own pages.
36
+ #
37
+ # config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
38
+
22
39
  # ==========================================================================
23
40
  # CONTROLLER INTEGRATION
24
41
  # ==========================================================================
@@ -105,6 +122,19 @@ SupportDesk.configure do |config|
105
122
  #
106
123
  # config.announce_assignments = :first_only
107
124
  #
125
+ # The system line a thread opens with, posted inside the opening
126
+ # transaction and before the first message. A String with %{label},
127
+ # %{desk} and %{reply_within}, a Symbol naming an I18n key, a block given
128
+ # the ticket, or nil for no line at all.
129
+ #
130
+ # The second one is for a case the DESK opened
131
+ # (`lucia.open_support_conversation_with!(alice, "Vimos que…")`): it has a
132
+ # default, because a message from a desk somebody never wrote to has to
133
+ # explain itself.
134
+ #
135
+ # config.opening_line = "Has abierto una conversación sobre «%{label}». Te contestamos aquí."
136
+ # config.opening_line_from_support = "%{desk} ha abierto esta conversación contigo sobre «%{label}»."
137
+ #
108
138
  # What a requester writing into a closed ticket does:
109
139
  # :reopen_on_reply the case comes back (no wall, no dead end)
110
140
  # :locked the composer is replaced by a notice
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module SupportDesk
7
+ module Generators
8
+ # `rails generate support_desk:upgrade` — copy the migrations a version
9
+ # bump needs into an EXISTING install. Nothing else: the initializer, the
10
+ # views, the console and the routes you already own stay untouched.
11
+ #
12
+ # Currently writes the 0.2.0 migration (who opened the case), which is the
13
+ # SAME template a fresh install runs — one file owns those columns, so a
14
+ # fresh install and an upgraded one end up with the same schema and the
15
+ # same rollback. Running this twice writes nothing the second time: the
16
+ # migration already sitting in db/migrate is identical, and Rails skips it.
17
+ class UpgradeGenerator < Rails::Generators::Base
18
+ include ActiveRecord::Generators::Migration
19
+
20
+ source_root File.expand_path("templates", __dir__)
21
+ desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by)"
22
+
23
+ # Rails' migration numbering, borrowed from ActiveRecord's generators.
24
+ def self.next_migration_number(dir)
25
+ ActiveRecord::Generators::Base.next_migration_number(dir)
26
+ end
27
+
28
+ def create_opened_by_migration
29
+ migration_template "add_opened_by_to_support_desk_tickets.rb.erb",
30
+ File.join(db_migrate_path, "add_opened_by_to_support_desk_tickets.rb")
31
+ end
32
+
33
+ def display_post_upgrade_message
34
+ say "\n🎫 support_desk upgrade migrations copied.", :green
35
+ say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
36
+ say " case at its requester — 0.1 had no other way to open one."
37
+ say " 2. New in 0.2.0:"
38
+ say " lucia.open_support_conversation_with!(alice, \"Vimos que…\") # the desk writes first"
39
+ say " ticket.opened_by / opened_by_support? / opened_by_requester?"
40
+ say " config.opening_line / config.opening_line_from_support / config.find_requester"
41
+ say " has_support_tickets if: :kept? # who may be written to"
42
+ say " 3. Migrate BEFORE 0.2 serves traffic. Pause support writes; stop and drain"
43
+ say " ALL old web requests and workers. This is NOT a rolling deployment."
44
+ say " Keep traffic paused, run 'rake support_desk:backfill_opened_by' under 0.2,"
45
+ say " verify no NULL openers remain, then start only 0.2 and resume traffic."
46
+ say " 4. See the CHANGELOG for the full list.\n", :green
47
+ end
48
+
49
+ private
50
+
51
+ def migration_version
52
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -50,10 +50,21 @@ module SupportDesk
50
50
  # fails at boot instead of silently leaving tickets unassigned.
51
51
  UNRELEASED_ROUTING_STRATEGIES = %i[round_robin least_loaded].freeze
52
52
 
53
+ # The sample a static opening line is interpolated against the moment it
54
+ # is assigned, so a typo'd %{labe} is a boot failure and not a 3am
55
+ # exception in the middle of somebody opening a case.
56
+ LINE_INTERPOLATIONS = { label: "…", desk: "…", reply_within: "…" }.freeze
57
+
53
58
  DEFAULTS = {
54
59
  name: nil,
55
60
  avatar: nil,
56
61
  email: nil,
62
+ opening_line: nil,
63
+ # A message from a desk you never wrote to has to explain itself, so
64
+ # this one has a default and `opening_line` does not: existing hosts'
65
+ # threads keep opening exactly as they do today.
66
+ opening_line_from_support: :"support_desk.thread.opened_by_support",
67
+ find_requester: nil,
57
68
  reply_policy: :anyone,
58
69
  announce_assignments: :first_only,
59
70
  closed_tickets: :reopen_on_reply,
@@ -116,6 +127,109 @@ module SupportDesk
116
127
  @settings[:email] = value&.to_s
117
128
  end
118
129
 
130
+ # --- What a thread opens with ---------------------------------------------
131
+
132
+ # The system line every thread opens with, posted INSIDE the opening
133
+ # transaction and before the first message, so it can never arrive
134
+ # after the message it introduces (or not at all).
135
+ #
136
+ # A String with %{label}, %{desk} and %{reply_within}; a Symbol naming
137
+ # an I18n key that takes the same interpolations; a block given the
138
+ # ticket; or nil for no line at all, which is the default.
139
+ #
140
+ # config.opening_line = "Has abierto una conversación sobre «%{label}»."
141
+ # config.opening_line { |ticket| ticket.subject ? … : … }
142
+ def opening_line(&block)
143
+ return @settings[:opening_line] = block if block
144
+
145
+ read(:opening_line)
146
+ end
147
+
148
+ # Set it, validating on assignment (see the reader above).
149
+ def opening_line=(value)
150
+ @settings[:opening_line] = ensure_line(value, "opening_line")
151
+ end
152
+
153
+ # The same line for a case the DESK opened. Defaults to the gem's own
154
+ # I18n key, because somebody who never wrote to you needs to be told
155
+ # what this is.
156
+ def opening_line_from_support(&block)
157
+ return @settings[:opening_line_from_support] = block if block
158
+
159
+ read(:opening_line_from_support)
160
+ end
161
+
162
+ # Set it, validating on assignment (see the reader above).
163
+ def opening_line_from_support=(value)
164
+ @settings[:opening_line_from_support] = ensure_line(value, "opening_line_from_support")
165
+ end
166
+
167
+ # How a console finds the person an agent types: email, phone, handle,
168
+ # whatever this host lets staff search by. Given the typed string,
169
+ # returns a requester record or nil.
170
+ #
171
+ # config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
172
+ #
173
+ # nil (the default) means the console accepts only a GlobalID from one
174
+ # of your own pages. Multi-tenant hosts scope BOTH ways in — this hook
175
+ # is a lookup, never an authorization.
176
+ def find_requester(&block)
177
+ return @settings[:find_requester] = block if block
178
+
179
+ read(:find_requester)
180
+ end
181
+
182
+ # Set it, validating on assignment (see the reader above).
183
+ def find_requester=(value)
184
+ @settings[:find_requester] = value.nil? ? nil : ensure_callable(value, "find_requester")
185
+ end
186
+
187
+ # What's wrong with this desk's opening lines, as sentences — what
188
+ # `doctor` reports. A String is interpolated against the sample, a
189
+ # Symbol has to exist in the current locale, and a block is left alone:
190
+ # it needs a ticket, and running a host's callback as a diagnostic is
191
+ # not a diagnostic.
192
+ def opening_line_problems # :nodoc:
193
+ %i[opening_line opening_line_from_support].filter_map do |setting|
194
+ value = public_send(setting)
195
+ next if value.nil? || value.respond_to?(:call)
196
+
197
+ if value.is_a?(Symbol)
198
+ next if I18n.exists?(value)
199
+
200
+ "#{setting} names #{value.inspect}, which has no #{I18n.locale} translation"
201
+ else
202
+ begin
203
+ interpolate_line(value, LINE_INTERPOLATIONS, setting.to_s)
204
+ nil
205
+ rescue ConfigurationError => e
206
+ e.message
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ # What to post for THIS ticket, resolved and interpolated in the
213
+ # current locale. nil or blank means post nothing.
214
+ def opening_line_for(ticket)
215
+ setting = ticket.opened_by_support? ? opening_line_from_support : opening_line
216
+ return nil if setting.nil?
217
+
218
+ line = case setting
219
+ when Symbol then I18n.t(setting, **line_interpolations(ticket), raise: true)
220
+ when String then interpolate_line(setting, line_interpolations(ticket), "opening_line")
221
+ else setting.call(ticket)
222
+ end
223
+ return nil if line.nil?
224
+
225
+ unless line.is_a?(String)
226
+ raise ConfigurationError,
227
+ "an opening_line block must return a String or nil, got #{line.inspect}"
228
+ end
229
+
230
+ line
231
+ end
232
+
119
233
  # --- Who answers ----------------------------------------------------------
120
234
 
121
235
  # The agent pool: notified while a ticket is unassigned, offered in the
@@ -372,14 +486,46 @@ module SupportDesk
372
486
 
373
487
  value
374
488
  end
489
+
490
+ # nil, a String, an I18n key, or something to call. A String is
491
+ # interpolated here and now against the sample, so an unknown
492
+ # placeholder fails at boot rather than inside a transaction.
493
+ def ensure_line(value, name)
494
+ return value if value.nil? || value.is_a?(Symbol) || value.respond_to?(:call)
495
+ if value.is_a?(String)
496
+ interpolate_line(value, LINE_INTERPOLATIONS, name)
497
+ return value
498
+ end
499
+
500
+ raise ConfigurationError,
501
+ "#{name} must be a String, an I18n key (Symbol), a block, or nil, got #{value.inspect}"
502
+ end
503
+
504
+ # Named interpolation, NOT String#%: "100% ready" is ordinary copy in
505
+ # any language, and `%` would read that as a format directive and
506
+ # raise. I18n.interpolate leaves a literal percent alone and only
507
+ # touches %{named} placeholders.
508
+ def interpolate_line(line, interpolations, name)
509
+ I18n.interpolate(line, interpolations)
510
+ rescue KeyError, ArgumentError => e
511
+ raise ConfigurationError,
512
+ "#{name} can't be interpolated (#{e.class}: #{e.message}). The placeholders it can use are " \
513
+ "#{LINE_INTERPOLATIONS.keys.map { |key| "%{#{key}}" }.join(", ")}."
514
+ end
515
+
516
+ def line_interpolations(ticket)
517
+ { label: ticket.label, desk: ticket.desk.name,
518
+ reply_within: (SupportDesk.humanize_duration(reply_within) if reply_within) }
519
+ end
375
520
  end
376
521
 
377
522
  # Settings that belong to a desk rather than the installation. The
378
523
  # top-level accessors forward to the `:default` desk, which is also what
379
524
  # every other desk falls back to.
380
525
  DESK_SETTINGS = %i[
381
- name avatar email reply_policy announce_assignments closed_tickets reply_within at_risk_after
382
- open_rate_limit max_open_tickets inbox_entry routing mirror_replies_by_email auto_close_after
526
+ name avatar email opening_line opening_line_from_support find_requester reply_policy
527
+ announce_assignments closed_tickets reply_within at_risk_after open_rate_limit max_open_tickets
528
+ inbox_entry routing mirror_replies_by_email auto_close_after
383
529
  ].freeze
384
530
 
385
531
  delegate(*DESK_SETTINGS, *DESK_SETTINGS.map { |setting| :"#{setting}=" }, to: :default_desk)