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,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSupportDeskTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # support_desk_desks
9
+ #
10
+ # Who answers. A desk IS a chats messager (headless: no notifications of
11
+ # its own, not blockable, grouped inbox row), so its rows are tiny — the
12
+ # name, avatar and email live in your initializer; `settings` is the
13
+ # runtime override for hosts that let staff rename a desk from a console.
14
+ # ---------------------------------------------------------------------------
15
+ create_table :support_desk_desks, id: primary_key_type do |t|
16
+ t.string :key, null: false
17
+ t.send(json_column_type, :settings, default: json_column_default)
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :support_desk_desks, :key, unique: true, name: "index_support_desk_desks_on_key"
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # support_desk_tickets
26
+ #
27
+ # A case. One ticket ⇔ one chats conversation: `conversation_id` is
28
+ # always set (by Ticket.open!, inside the same transaction as the row),
29
+ # which is why the column is nullable — the conversation is *about* the
30
+ # ticket, so the ticket has to exist first. `SupportDesk.doctor` checks
31
+ # that no ticket is ever left without one.
32
+ #
33
+ # The denormalized columns are all answers to "what does the queue need
34
+ # without a join?": awaiting (who owes the next word), waiting_since
35
+ # (how long), assignee (who holds it), and the SLA clocks.
36
+ # ---------------------------------------------------------------------------
37
+ create_table :support_desk_tickets, id: primary_key_type do |t|
38
+ t.references :desk, null: false, type: foreign_key_type,
39
+ foreign_key: { to_table: :support_desk_desks }, index: false
40
+
41
+ t.references :requester, polymorphic: true, null: false, type: foreign_key_type, index: false
42
+ t.string :requester_role
43
+ t.references :subject, polymorphic: true, null: true, type: foreign_key_type, index: false
44
+ t.references :assignee, polymorphic: true, null: true, type: foreign_key_type, index: false
45
+ t.references :closed_by, polymorphic: true, null: true, type: foreign_key_type, index: false
46
+
47
+ # The topic path ("payments/withdrawal"), stable across tree edits.
48
+ t.string :topic, null: false
49
+ # Set by email (the mail subject) and by the API; never by the wizard.
50
+ t.string :title
51
+ # "T-AB12CD" — Crockford base32, for email subjects and phone calls.
52
+ t.string :reference, null: false
53
+
54
+ t.string :status, null: false, default: "open"
55
+ t.string :awaiting, null: false, default: "agent"
56
+ t.integer :priority, null: false, default: 0
57
+ t.string :opened_via, null: false, default: "in_app"
58
+
59
+ t.column :conversation_id, foreign_key_type
60
+ # The message id #register! last folded in — what makes a redelivered
61
+ # chats event idempotent instead of double-counting an SLA clock.
62
+ t.column :last_registered_message_id, foreign_key_type
63
+
64
+ t.datetime :opened_at, null: false
65
+ t.datetime :first_agent_reply_at
66
+ t.datetime :last_requester_message_at
67
+ t.datetime :last_agent_message_at
68
+ # Maintained by the model (NOT a generated column): the same
69
+ # expression has to work on SQLite and MySQL, and a plain column is
70
+ # one indexable answer on every adapter.
71
+ t.datetime :waiting_since
72
+ t.datetime :snoozed_until
73
+ t.datetime :closed_at
74
+
75
+ t.integer :reopen_count, null: false, default: 0
76
+ t.string :external_ref
77
+ # What "one open ticket about this" means for this row: the subject,
78
+ # the topic, or a unique value when the supportable allows several.
79
+ t.string :cardinality_key, null: false
80
+ t.send(json_column_type, :metadata, default: json_column_default)
81
+
82
+ t.timestamps
83
+ end
84
+
85
+ add_index :support_desk_tickets, :reference, unique: true, name: "index_support_desk_tickets_on_reference"
86
+ add_index :support_desk_tickets, [ :requester_type, :requester_id, :status ],
87
+ name: "index_support_desk_tickets_on_requester"
88
+ add_index :support_desk_tickets, [ :desk_id, :status, :awaiting, :waiting_since ],
89
+ name: "index_support_desk_tickets_on_queue"
90
+ add_index :support_desk_tickets, [ :assignee_type, :assignee_id, :status ],
91
+ name: "index_support_desk_tickets_on_assignee"
92
+ add_index :support_desk_tickets, [ :subject_type, :subject_id ],
93
+ name: "index_support_desk_tickets_on_subject"
94
+ add_index :support_desk_tickets, :conversation_id, unique: true,
95
+ name: "index_support_desk_tickets_on_conversation_id"
96
+ add_index :support_desk_tickets, [ :desk_id, :external_ref ], unique: true,
97
+ name: "index_support_desk_tickets_on_external_ref"
98
+
99
+ # THE cardinality guarantee: one open ticket per requester per thing.
100
+ # A partial unique index is what makes concurrent opens race-safe
101
+ # without advisory locks — the second INSERT loses and Ticket.open!
102
+ # hands back the first one's ticket.
103
+ #
104
+ # PostgreSQL AND SQLite both support partial indexes, so both get real
105
+ # enforcement. MySQL doesn't, so there the model's find-then-create is
106
+ # the whole story and this is a plain lookup index: a host on MySQL that
107
+ # cares about the race should serialize opens itself.
108
+ if partial_indexes?
109
+ add_index :support_desk_tickets, [ :requester_type, :requester_id, :desk_id, :cardinality_key ],
110
+ unique: true, where: "status <> 'closed'",
111
+ name: "index_support_desk_tickets_on_open_cardinality"
112
+ else
113
+ add_index :support_desk_tickets, [ :requester_type, :requester_id, :desk_id, :cardinality_key ],
114
+ name: "index_support_desk_tickets_on_open_cardinality"
115
+ end
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # support_desk_assignments
119
+ #
120
+ # Who held the ticket, when, and why they stopped. Assignment is a
121
+ # history, not a column: hand-offs, drop-in take-overs, shift releases
122
+ # and "time per agent" are unanswerable from a single assignee_id.
123
+ # ---------------------------------------------------------------------------
124
+ create_table :support_desk_assignments, id: primary_key_type do |t|
125
+ t.references :ticket, null: false, type: foreign_key_type,
126
+ foreign_key: { to_table: :support_desk_tickets }, index: false
127
+ t.references :agent, polymorphic: true, null: false, type: foreign_key_type, index: false
128
+ t.references :assigned_by, polymorphic: true, null: true, type: foreign_key_type, index: false
129
+
130
+ t.string :reason, null: false
131
+ t.text :note
132
+ t.datetime :assigned_at, null: false
133
+ t.datetime :released_at
134
+ t.string :release_reason
135
+
136
+ t.timestamps
137
+ end
138
+
139
+ add_index :support_desk_assignments, [ :ticket_id, :assigned_at ],
140
+ name: "index_support_desk_assignments_on_ticket"
141
+ add_index :support_desk_assignments, [ :agent_type, :agent_id, :released_at ],
142
+ name: "index_support_desk_assignments_on_agent"
143
+
144
+ # At most one open assignment per ticket — the invariant behind
145
+ # `ticket.assignee`. Same story: enforced everywhere partial indexes
146
+ # exist, checked by `SupportDesk.doctor` everywhere else.
147
+ if partial_indexes?
148
+ add_index :support_desk_assignments, :ticket_id, unique: true, where: "released_at IS NULL",
149
+ name: "index_support_desk_assignments_on_open_ticket"
150
+ end
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # support_desk_events
154
+ #
155
+ # Append-only: everything that happened to a case and wasn't a message.
156
+ # No updated_at, because there is no update path — the model is readonly
157
+ # once written. Internal notes are events (kind "note"), never messages,
158
+ # so they are searchable and in the timeline but never in the
159
+ # conversation and never mirrored to any channel.
160
+ # ---------------------------------------------------------------------------
161
+ create_table :support_desk_events, id: primary_key_type do |t|
162
+ t.references :ticket, null: false, type: foreign_key_type,
163
+ foreign_key: { to_table: :support_desk_tickets }, index: false
164
+ t.references :actor, polymorphic: true, null: true, type: foreign_key_type, index: false
165
+
166
+ t.string :kind, null: false
167
+ t.send(json_column_type, :payload, default: json_column_default)
168
+
169
+ t.datetime :created_at, null: false
170
+ end
171
+
172
+ add_index :support_desk_events, [ :ticket_id, :created_at ], name: "index_support_desk_events_on_ticket"
173
+ add_index :support_desk_events, [ :ticket_id, :kind ], name: "index_support_desk_events_on_kind"
174
+
175
+ # NOTE: value-list vocabularies (status, awaiting, kind, reason) are
176
+ # validated in the MODELS (frozen constants + inclusion validations),
177
+ # NOT by DB check constraints — so the gem can grow its taxonomy without
178
+ # shipping a migration to widen a CHECK.
179
+ end
180
+
181
+ private
182
+
183
+ # Honor the host's configured primary key type (uuid vs bigint). Reads the
184
+ # same setting `rails g model` uses, so an app generated with
185
+ # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
186
+ # gets uuid support_desk tables and uuid foreign keys, automatically.
187
+ def primary_and_foreign_key_types
188
+ config = Rails.configuration.generators
189
+ setting = config.options[config.orm][:primary_key_type]
190
+ primary_key_type = setting || :primary_key
191
+ foreign_key_type = setting || :bigint
192
+ [ primary_key_type, foreign_key_type ]
193
+ end
194
+
195
+ # Whether this adapter can enforce a rule over SOME rows ("one open ticket
196
+ # per thing", "one open assignment per ticket"). Everything but MySQL can.
197
+ #
198
+ # Trilogy is MySQL, and Rails reports its ADAPTER_NAME as "Trilogy", which a
199
+ # /mysql/ pattern misses — such a host would be handed a partial unique
200
+ # index MySQL cannot create and the install would die on it. api_keys hit
201
+ # this first; see its create_api_keys_table template.
202
+ def partial_indexes?
203
+ !connection.adapter_name.match?(/mysql|trilogy/i)
204
+ end
205
+
206
+ # jsonb on every PostgreSQL adapter — matched by prefix because PostGIS
207
+ # (activerecord-postgis-adapter) answers "PostGIS", not "PostgreSQL", and
208
+ # an `include?("postgresql")` check silently sent such hosts down the plain
209
+ # json path. Surfaced while integrating with a PostGIS host.
210
+ def json_column_type
211
+ return :jsonb if connection.adapter_name.match?(/\Apostg/i)
212
+
213
+ :json
214
+ end
215
+
216
+ # MySQL 8+ doesn't allow default values on JSON columns. Trilogy is MySQL
217
+ # under a different ADAPTER_NAME, so match both or a Trilogy host is handed
218
+ # a default MySQL rejects.
219
+ def json_column_default
220
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
221
+
222
+ {}
223
+ end
224
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ SupportDesk.configure do |config|
4
+ # ==========================================================================
5
+ # WHO ASKS, WHO ANSWERS
6
+ # ==========================================================================
7
+ #
8
+ # The model that asks for help — the one with `has_support_tickets`. It
9
+ # must also be a chats messager (`acts_as_messager`): a requester holds a
10
+ # seat in the conversation behind every one of their tickets.
11
+ #
12
+ # Default: "User"
13
+ config.requester_class = "User"
14
+
15
+ # The agent pool: who gets notified while a ticket is unassigned, who
16
+ # appears in the "assign to" picker, and who routing may choose. A block
17
+ # (or a lambda) returning a relation — it's called when it's needed, so it
18
+ # always reflects today's staff.
19
+ #
20
+ # config.agents { User.where(admin: true) }
21
+
22
+ # ==========================================================================
23
+ # CONTROLLER INTEGRATION
24
+ # ==========================================================================
25
+ #
26
+ # The requester-facing engine inherits from your controller, so your
27
+ # layout, helpers, auth and locale apply to the support screens
28
+ # automatically.
29
+ #
30
+ # config.parent_controller = "::ApplicationController"
31
+ #
32
+ # The console (the optional ConsoleEngine and the generated console)
33
+ # inherits from your admin framework's base controller instead.
34
+ #
35
+ # config.console_parent_controller = "::Madmin::ApplicationController"
36
+ #
37
+ # How the engine finds the person asking, and the console the person
38
+ # answering. The defaults work with Devise out of the box.
39
+ #
40
+ # config.current_requester_method = :current_user
41
+ # config.current_agent_method = :current_user
42
+ #
43
+ # Your own authentication filter, run before every requester-facing
44
+ # screen, so a logged-out visitor meets YOUR login flow.
45
+ #
46
+ # config.authenticate_method = :authenticate_user!
47
+
48
+ # ==========================================================================
49
+ # THE DESK
50
+ # ==========================================================================
51
+ #
52
+ # What requesters see as the counterpart in their inbox.
53
+ #
54
+ # config.name = "Soporte"
55
+ #
56
+ # An asset path, a URL, or ->(desk) { … }. Anything image_tag accepts.
57
+ #
58
+ # config.avatar = "support-avatar.png"
59
+ #
60
+ # The address the email channel answers from (support_desk 0.2).
61
+ #
62
+ # config.email = "soporte@example.com"
63
+
64
+ # ==========================================================================
65
+ # TOPICS — what a ticket can be about
66
+ # ==========================================================================
67
+ #
68
+ # A tree, defined here, stored on the ticket as a stable path
69
+ # ("payments/withdrawal"). Topics carry behaviour — which records they
70
+ # attach, which picker, which routing, which prefill — which is why they
71
+ # live in code rather than in a database table somebody edits at 3am.
72
+ #
73
+ # Labels come from i18n (support_desk.topics.<path>.label) unless you pass
74
+ # `label:`. `other` is the free-form leaf; a taxonomy without an exit is
75
+ # how people pick the wrong topic, so the gem warns at boot when it's
76
+ # missing.
77
+ #
78
+ # config.topics do
79
+ # topic :ride, about: Ride
80
+ # topic :payments do
81
+ # topic :withdrawal, about: Payouts::Withdrawal
82
+ # topic :invoice, desk: :billing
83
+ # end
84
+ # topic :account, only: ->(user) { user.onboarded? }
85
+ # topic :safety, priority: :urgent
86
+ # other
87
+ # end
88
+
89
+ # ==========================================================================
90
+ # BEHAVIOUR
91
+ # ==========================================================================
92
+ #
93
+ # Who may answer a ticket somebody else holds:
94
+ # :anyone the reply posts, signed by the drop-in; an unheld
95
+ # ticket is taken by whoever answers first (small teams)
96
+ # :take_over replying reassigns the ticket to the replier (shifts)
97
+ # :assignee_only raises; the console offers "Tomar" instead (regulated)
98
+ #
99
+ # config.reply_policy = :anyone
100
+ #
101
+ # Whether the requester is told who picked up their ticket:
102
+ # :first_only the first human to take it ("Lucía se ocupa de tu consulta")
103
+ # :always hand-offs too
104
+ # :never
105
+ #
106
+ # config.announce_assignments = :first_only
107
+ #
108
+ # What a requester writing into a closed ticket does:
109
+ # :reopen_on_reply the case comes back (no wall, no dead end)
110
+ # :locked the composer is replaced by a notice
111
+ #
112
+ # config.closed_tickets = :reopen_on_reply
113
+ #
114
+ # The answer promise: the SLA breach threshold AND the line requesters are
115
+ # shown when they write ("normalmente en menos de 24 h"). One setting, one
116
+ # truth. `at_risk_after` is the earlier, softer warning for the queue.
117
+ #
118
+ # config.reply_within = 24.hours
119
+ # config.at_risk_after = 4.hours
120
+ #
121
+ # Abuse limits, per requester. Both are walls against one person
122
+ # hammering the button, and both are checked before the insert rather
123
+ # than under a lock — so two requests racing about two different things
124
+ # can leave somebody one ticket over the cap. That is deliberate: the
125
+ # alternative locks your own users table on every support ticket, and
126
+ # nobody is harmed by a sixth open case.
127
+ #
128
+ # config.open_rate_limit = { to: 5, within: 1.hour }
129
+ # config.max_open_tickets = 5
130
+ #
131
+ # Whether the desk shows in the requester's inbox before they've written:
132
+ # :always (a "¿Necesitas ayuda?" door), :when_tickets, or :never.
133
+ #
134
+ # config.inbox_entry = :always
135
+ #
136
+ # How new tickets find an agent. 0.1 ships :manual (unassigned, the pool
137
+ # is notified, the first take wins) and ->(ticket) { agent } procs.
138
+ #
139
+ # config.routing = :manual
140
+ #
141
+ # The email channel's two settings, ahead of support_desk 0.2: whether an
142
+ # agent's reply is also emailed to the requester, and whether a case that
143
+ # has been waiting on the requester closes itself.
144
+ #
145
+ # config.mirror_replies_by_email = :when_away # :always | :when_away | :never
146
+ # config.auto_close_after = nil # e.g. 7.days
147
+
148
+ # ==========================================================================
149
+ # MORE THAN ONE DESK
150
+ # ==========================================================================
151
+ #
152
+ # Everything above configures the :default desk. Other desks inherit from
153
+ # it and override what they need.
154
+ #
155
+ # config.desk :billing do |desk|
156
+ # desk.name = "Facturación"
157
+ # desk.reply_within = 8.hours
158
+ # desk.agents { User.where(finance: true) }
159
+ # end
160
+
161
+ # ==========================================================================
162
+ # EVENTS — the gem emits, your app delivers
163
+ # ==========================================================================
164
+ #
165
+ # Multi-subscriber and error-isolated: a subscriber that raises is
166
+ # reported and the next one still runs. All of them fire after the
167
+ # transition has committed.
168
+ #
169
+ # config.on(:ticket_opened) do |ticket|
170
+ # TicketActivityNotifier.with(ticket: ticket).deliver(ticket.agents_to_notify)
171
+ # end
172
+ #
173
+ # config.on(:requester_replied) do |ticket, message|
174
+ # TicketActivityNotifier.with(ticket: ticket, record: message).deliver(ticket.agents_to_notify)
175
+ # end
176
+ #
177
+ # The umbrella event every transition also emits — the audit mirror hook.
178
+ #
179
+ # config.on(:ticket_transitioned) do |ticket, kind, by:, request:, payload:|
180
+ # AuditLog.log("support_ticket_#{kind}", actor: by, request: request, subject: ticket, **payload)
181
+ # end
182
+ #
183
+ # Keep notification TITLES generic (ticket.notification_title does) and put
184
+ # the detail in the body: a lock screen shouldn't spell out what somebody's
185
+ # support case is about.
186
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module SupportDesk
6
+ module Generators
7
+ # `rails generate support_desk:views` — eject the requester-facing
8
+ # templates into the HOST app so they can be restyled. This is the Devise
9
+ # move (`rails g devise:views`), and it works for the same boring Rails
10
+ # reason: the host app's `app/views` sits AHEAD of any engine's view paths
11
+ # in the lookup chain, so a file copied to e.g.
12
+ # `app/views/support_desk/tickets/index.html.erb` SHADOWS the gem's
13
+ # bundled default automatically — no config, no registration. Delete your
14
+ # copy and the gem's default comes back. Upgrade the gem and your ejected
15
+ # copies are untouched (re-run only if you WANT the new defaults).
16
+ #
17
+ # `source_root` points at the engine's own `app/views`, so `directory`
18
+ # copies the exact templates the engine renders.
19
+ class ViewsGenerator < Rails::Generators::Base
20
+ source_root File.expand_path("../../../app/views", __dir__)
21
+
22
+ desc "Copy support_desk's requester-facing views into your app so you can restyle them."
23
+
24
+ # Which groups to eject. `tickets` is every requester screen (the list,
25
+ # the wizard's three frames, the rows and the doors); `slots` is the
26
+ # row this engine contributes to chats' inbox.
27
+ class_option :views,
28
+ type: :array,
29
+ default: %w[tickets slots],
30
+ desc: "Which view groups to copy (tickets, slots)"
31
+
32
+ def copy_views
33
+ directory "support_desk/tickets", "app/views/support_desk/tickets" if include?("tickets")
34
+ directory "chats/slots", "app/views/chats/slots" if include?("slots")
35
+ end
36
+
37
+ def show_styling_tip
38
+ say "\n🎨 Views copied. They render with the gem's bundled support_desk.css (and chats.css"
39
+ say " for the rows) by default; restyle freely — if your app uses Tailwind, classes you"
40
+ say " add here are picked up by your build automatically (the files now live in app/views)."
41
+ end
42
+
43
+ private
44
+
45
+ def include?(group)
46
+ options[:views].map(&:to_s).include?(group)
47
+ end
48
+ end
49
+ end
50
+ end