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.
- checksums.yaml +7 -0
- data/.rubocop.yml +32 -0
- data/.simplecov +53 -0
- data/Appraisals +18 -0
- data/CHANGELOG.md +135 -0
- data/LICENSE.txt +21 -0
- data/README.md +469 -0
- data/Rakefile +55 -0
- data/app/assets/stylesheets/support_desk.css +242 -0
- data/app/controllers/support_desk/application_controller.rb +92 -0
- data/app/controllers/support_desk/console/application_controller.rb +18 -0
- data/app/controllers/support_desk/console/tickets_controller.rb +26 -0
- data/app/controllers/support_desk/tickets_controller.rb +119 -0
- data/app/helpers/support_desk/engine_helper.rb +196 -0
- data/app/views/chats/slots/_inbox_top.html.erb +31 -0
- data/app/views/chats/slots/_locked_composer.html.erb +18 -0
- data/app/views/support_desk/console/tickets/_actions.html.erb +29 -0
- data/app/views/support_desk/console/tickets/_assignment.html.erb +60 -0
- data/app/views/support_desk/console/tickets/_composer.html.erb +71 -0
- data/app/views/support_desk/console/tickets/_context_card.html.erb +53 -0
- data/app/views/support_desk/console/tickets/_message.html.erb +48 -0
- data/app/views/support_desk/console/tickets/_nav_badge.html.erb +19 -0
- data/app/views/support_desk/console/tickets/_tabs.html.erb +15 -0
- data/app/views/support_desk/console/tickets/_ticket_row.html.erb +56 -0
- data/app/views/support_desk/console/tickets/_timeline.html.erb +37 -0
- data/app/views/support_desk/console/tickets/_transcript.html.erb +26 -0
- data/app/views/support_desk/console/tickets/index.html.erb +37 -0
- data/app/views/support_desk/console/tickets/show.html.erb +45 -0
- data/app/views/support_desk/tickets/_context_card.html.erb +14 -0
- data/app/views/support_desk/tickets/_door.html.erb +12 -0
- data/app/views/support_desk/tickets/_pick_thing.html.erb +55 -0
- data/app/views/support_desk/tickets/_pick_topic.html.erb +30 -0
- data/app/views/support_desk/tickets/_ticket_row.html.erb +31 -0
- data/app/views/support_desk/tickets/_wizard_header.html.erb +23 -0
- data/app/views/support_desk/tickets/_write.html.erb +56 -0
- data/app/views/support_desk/tickets/index.html.erb +53 -0
- data/app/views/support_desk/tickets/new.html.erb +14 -0
- data/app/views/support_desk/tickets/rate_limited.html.erb +40 -0
- data/config/console_routes.rb +16 -0
- data/config/locales/support_desk.console.en.yml +93 -0
- data/config/locales/support_desk.console.es.yml +93 -0
- data/config/locales/support_desk.en.yml +79 -0
- data/config/locales/support_desk.es.yml +84 -0
- data/config/routes.rb +24 -0
- data/context7.json +4 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/support_desk/console_generator.rb +94 -0
- data/lib/generators/support_desk/install_generator.rb +86 -0
- data/lib/generators/support_desk/templates/console/controller.rb.erb +43 -0
- data/lib/generators/support_desk/templates/console/resource.rb.erb +44 -0
- data/lib/generators/support_desk/templates/create_support_desk_tables.rb.erb +224 -0
- data/lib/generators/support_desk/templates/initializer.rb +186 -0
- data/lib/generators/support_desk/views_generator.rb +50 -0
- data/lib/support_desk/configuration.rb +675 -0
- data/lib/support_desk/console.rb +487 -0
- data/lib/support_desk/console_engine.rb +63 -0
- data/lib/support_desk/console_routes.rb +107 -0
- data/lib/support_desk/context_card.rb +90 -0
- data/lib/support_desk/current.rb +26 -0
- data/lib/support_desk/doctor.rb +220 -0
- data/lib/support_desk/engine.rb +141 -0
- data/lib/support_desk/errors.rb +49 -0
- data/lib/support_desk/events.rb +122 -0
- data/lib/support_desk/macros.rb +73 -0
- data/lib/support_desk/models/application_record.rb +11 -0
- data/lib/support_desk/models/assignment.rb +83 -0
- data/lib/support_desk/models/concerns/agent.rb +79 -0
- data/lib/support_desk/models/concerns/requester.rb +71 -0
- data/lib/support_desk/models/concerns/supportable.rb +88 -0
- data/lib/support_desk/models/desk.rb +101 -0
- data/lib/support_desk/models/event.rb +72 -0
- data/lib/support_desk/models/ticket.rb +1124 -0
- data/lib/support_desk/queue.rb +171 -0
- data/lib/support_desk/summary.rb +70 -0
- data/lib/support_desk/test_helpers.rb +137 -0
- data/lib/support_desk/timeline.rb +104 -0
- data/lib/support_desk/topic.rb +290 -0
- data/lib/support_desk/topic_tree.rb +214 -0
- data/lib/support_desk/version.rb +5 -0
- data/lib/support_desk/wizard.rb +392 -0
- data/lib/support_desk.rb +288 -0
- 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
|