escalated 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +302 -0
  4. data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
  5. data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
  6. data/app/controllers/escalated/admin/departments_controller.rb +135 -0
  7. data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
  8. data/app/controllers/escalated/admin/macros_controller.rb +73 -0
  9. data/app/controllers/escalated/admin/reports_controller.rb +152 -0
  10. data/app/controllers/escalated/admin/settings_controller.rb +111 -0
  11. data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
  12. data/app/controllers/escalated/admin/tags_controller.rb +67 -0
  13. data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
  14. data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
  15. data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
  16. data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
  17. data/app/controllers/escalated/application_controller.rb +110 -0
  18. data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
  19. data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
  20. data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
  21. data/app/controllers/escalated/inbound_controller.rb +79 -0
  22. data/app/jobs/escalated/check_sla_job.rb +36 -0
  23. data/app/jobs/escalated/close_resolved_job.rb +51 -0
  24. data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
  25. data/app/jobs/escalated/poll_imap_job.rb +74 -0
  26. data/app/jobs/escalated/purge_activities_job.rb +24 -0
  27. data/app/mailers/escalated/application_mailer.rb +6 -0
  28. data/app/mailers/escalated/ticket_mailer.rb +93 -0
  29. data/app/models/escalated/application_record.rb +5 -0
  30. data/app/models/escalated/attachment.rb +46 -0
  31. data/app/models/escalated/canned_response.rb +45 -0
  32. data/app/models/escalated/department.rb +43 -0
  33. data/app/models/escalated/escalated_setting.rb +43 -0
  34. data/app/models/escalated/escalation_rule.rb +96 -0
  35. data/app/models/escalated/inbound_email.rb +60 -0
  36. data/app/models/escalated/macro.rb +18 -0
  37. data/app/models/escalated/reply.rb +42 -0
  38. data/app/models/escalated/satisfaction_rating.rb +21 -0
  39. data/app/models/escalated/sla_policy.rb +54 -0
  40. data/app/models/escalated/tag.rb +28 -0
  41. data/app/models/escalated/ticket.rb +166 -0
  42. data/app/models/escalated/ticket_activity.rb +60 -0
  43. data/app/policies/escalated/canned_response_policy.rb +40 -0
  44. data/app/policies/escalated/department_policy.rb +36 -0
  45. data/app/policies/escalated/escalation_rule_policy.rb +36 -0
  46. data/app/policies/escalated/sla_policy_policy.rb +36 -0
  47. data/app/policies/escalated/tag_policy.rb +36 -0
  48. data/app/policies/escalated/ticket_policy.rb +111 -0
  49. data/config/routes.rb +81 -0
  50. data/db/migrate/001_create_escalated_departments.rb +18 -0
  51. data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
  52. data/db/migrate/003_create_escalated_tags.rb +15 -0
  53. data/db/migrate/004_create_escalated_tickets.rb +48 -0
  54. data/db/migrate/005_create_escalated_replies.rb +21 -0
  55. data/db/migrate/006_create_escalated_attachments.rb +17 -0
  56. data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
  57. data/db/migrate/008_create_escalated_support_tables.rb +49 -0
  58. data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
  59. data/db/migrate/010_create_escalated_settings.rb +29 -0
  60. data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
  61. data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
  62. data/db/migrate/013_create_escalated_macros.rb +18 -0
  63. data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
  64. data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
  65. data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
  66. data/lib/escalated/configuration.rb +111 -0
  67. data/lib/escalated/drivers/cloud_driver.rb +134 -0
  68. data/lib/escalated/drivers/hosted_api_client.rb +166 -0
  69. data/lib/escalated/drivers/local_driver.rb +341 -0
  70. data/lib/escalated/drivers/synced_driver.rb +124 -0
  71. data/lib/escalated/engine.rb +45 -0
  72. data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
  73. data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
  74. data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
  75. data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
  76. data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
  77. data/lib/escalated/mail/inbound_message.rb +78 -0
  78. data/lib/escalated/manager.rb +33 -0
  79. data/lib/escalated/services/assignment_service.rb +85 -0
  80. data/lib/escalated/services/attachment_service.rb +110 -0
  81. data/lib/escalated/services/escalation_service.rb +159 -0
  82. data/lib/escalated/services/inbound_email_service.rb +255 -0
  83. data/lib/escalated/services/macro_service.rb +49 -0
  84. data/lib/escalated/services/notification_service.rb +157 -0
  85. data/lib/escalated/services/sla_service.rb +203 -0
  86. data/lib/escalated/services/ticket_service.rb +113 -0
  87. data/lib/escalated.rb +25 -0
  88. data/lib/generators/escalated/install_generator.rb +75 -0
  89. data/lib/generators/escalated/templates/initializer.rb +89 -0
  90. metadata +227 -0
@@ -0,0 +1,330 @@
1
+ module Escalated
2
+ module Agent
3
+ class TicketsController < Escalated::ApplicationController
4
+ before_action :require_agent!
5
+ before_action :set_ticket, only: [:show, :update, :reply, :note, :assign, :status, :priority, :tags, :department, :apply_macro, :follow, :presence, :pin]
6
+
7
+ def index
8
+ scope = Escalated::Ticket.all.recent
9
+
10
+ scope = scope.where(status: params[:status]) if params[:status].present?
11
+ scope = scope.where(priority: params[:priority]) if params[:priority].present?
12
+ scope = scope.assigned_to(params[:assigned_to]) if params[:assigned_to].present?
13
+ scope = scope.where(department_id: params[:department_id]) if params[:department_id].present?
14
+ scope = scope.unassigned if params[:unassigned] == "true"
15
+ scope = scope.breached_sla if params[:sla_breached] == "true"
16
+ scope = scope.search(params[:search]) if params[:search].present?
17
+
18
+ # Following filter: tickets the current user follows
19
+ if params[:following] == "true"
20
+ followed_ticket_ids = Escalated::Ticket
21
+ .joins("INNER JOIN #{Escalated.table_name('ticket_followers')} ON #{Escalated.table_name('ticket_followers')}.ticket_id = #{Escalated.table_name('tickets')}.id")
22
+ .where("#{Escalated.table_name('ticket_followers')}.user_id = ?", escalated_current_user.id)
23
+ .pluck(:id)
24
+ scope = scope.where(id: followed_ticket_ids)
25
+ end
26
+
27
+ result = paginate(scope)
28
+
29
+ render inertia: "Escalated/Agent/TicketIndex", props: {
30
+ tickets: result[:data].includes(:requester, :department, :assignee, :tags).map { |t| ticket_list_json(t) },
31
+ meta: result[:meta],
32
+ filters: {
33
+ status: params[:status],
34
+ priority: params[:priority],
35
+ assigned_to: params[:assigned_to],
36
+ department_id: params[:department_id],
37
+ unassigned: params[:unassigned],
38
+ sla_breached: params[:sla_breached],
39
+ search: params[:search],
40
+ following: params[:following]
41
+ },
42
+ departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
43
+ agents: agent_list,
44
+ tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
45
+ statuses: Escalated::Ticket.statuses.keys,
46
+ priorities: Escalated::Ticket.priorities.keys
47
+ }
48
+ end
49
+
50
+ def show
51
+ authorize @ticket, policy_class: Escalated::TicketPolicy
52
+
53
+ replies = @ticket.replies.chronological.includes(:author, :attachments)
54
+ activities = @ticket.activities.reverse_chronological.limit(50)
55
+
56
+ render inertia: "Escalated/Agent/TicketShow", props: {
57
+ ticket: ticket_detail_json(@ticket),
58
+ replies: replies.map { |r| reply_json(r) },
59
+ activities: activities.map { |a| activity_json(a) },
60
+ departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
61
+ agents: agent_list,
62
+ tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
63
+ canned_responses: Escalated::CannedResponse.for_user(escalated_current_user.id).ordered.map { |c|
64
+ { id: c.id, title: c.title, body: c.body, shortcode: c.shortcode }
65
+ },
66
+ macros: Escalated::Macro.for_agent(escalated_current_user.id).ordered.map { |m|
67
+ { id: m.id, name: m.name, description: m.description, actions: m.actions }
68
+ },
69
+ is_following: @ticket.followed_by?(escalated_current_user.id),
70
+ followers_count: @ticket.followers.count,
71
+ statuses: Escalated::Ticket.statuses.keys,
72
+ priorities: Escalated::Ticket.priorities.keys
73
+ }
74
+ end
75
+
76
+ def update
77
+ authorize @ticket, policy_class: Escalated::TicketPolicy
78
+
79
+ Services::TicketService.update(@ticket, update_params, actor: escalated_current_user)
80
+ redirect_to agent_ticket_path(@ticket), notice: "Ticket updated."
81
+ end
82
+
83
+ def reply
84
+ authorize @ticket, policy_class: Escalated::TicketPolicy
85
+
86
+ reply = Services::TicketService.reply(@ticket, {
87
+ body: params[:body],
88
+ author: escalated_current_user,
89
+ is_internal: false
90
+ })
91
+
92
+ if params[:attachments].present?
93
+ Services::AttachmentService.attach(reply, params[:attachments])
94
+ end
95
+
96
+ redirect_to agent_ticket_path(@ticket), notice: "Reply sent."
97
+ end
98
+
99
+ def note
100
+ authorize @ticket, policy_class: Escalated::TicketPolicy
101
+
102
+ Services::TicketService.reply(@ticket, {
103
+ body: params[:body],
104
+ author: escalated_current_user,
105
+ is_internal: true
106
+ })
107
+
108
+ redirect_to agent_ticket_path(@ticket), notice: "Internal note added."
109
+ end
110
+
111
+ def assign
112
+ authorize @ticket, policy_class: Escalated::TicketPolicy
113
+
114
+ if params[:agent_id].present?
115
+ agent = Escalated.configuration.user_model.find(params[:agent_id])
116
+ Services::AssignmentService.assign(@ticket, agent, actor: escalated_current_user)
117
+ redirect_to agent_ticket_path(@ticket), notice: "Ticket assigned to #{agent.respond_to?(:name) ? agent.name : agent.email}."
118
+ else
119
+ Services::AssignmentService.unassign(@ticket, actor: escalated_current_user)
120
+ redirect_to agent_ticket_path(@ticket), notice: "Ticket unassigned."
121
+ end
122
+ end
123
+
124
+ def status
125
+ authorize @ticket, policy_class: Escalated::TicketPolicy
126
+
127
+ Services::TicketService.transition_status(
128
+ @ticket,
129
+ params[:status],
130
+ actor: escalated_current_user,
131
+ note: params[:note]
132
+ )
133
+
134
+ redirect_to agent_ticket_path(@ticket), notice: "Status updated to #{params[:status].humanize}."
135
+ end
136
+
137
+ def priority
138
+ authorize @ticket, policy_class: Escalated::TicketPolicy
139
+
140
+ Services::TicketService.change_priority(@ticket, params[:priority], actor: escalated_current_user)
141
+ redirect_to agent_ticket_path(@ticket), notice: "Priority updated to #{params[:priority]}."
142
+ end
143
+
144
+ def tags
145
+ authorize @ticket, policy_class: Escalated::TicketPolicy
146
+
147
+ if params[:add_tag_ids].present?
148
+ Services::TicketService.add_tags(@ticket, params[:add_tag_ids], actor: escalated_current_user)
149
+ end
150
+
151
+ if params[:remove_tag_ids].present?
152
+ Services::TicketService.remove_tags(@ticket, params[:remove_tag_ids], actor: escalated_current_user)
153
+ end
154
+
155
+ redirect_to agent_ticket_path(@ticket), notice: "Tags updated."
156
+ end
157
+
158
+ def department
159
+ authorize @ticket, policy_class: Escalated::TicketPolicy
160
+
161
+ dept = Escalated::Department.find(params[:department_id])
162
+ Services::TicketService.change_department(@ticket, dept, actor: escalated_current_user)
163
+ redirect_to agent_ticket_path(@ticket), notice: "Department changed to #{dept.name}."
164
+ end
165
+
166
+ def apply_macro
167
+ authorize @ticket, policy_class: Escalated::TicketPolicy
168
+
169
+ macro = Escalated::Macro.for_agent(escalated_current_user.id).find(params[:macro_id])
170
+ Services::MacroService.apply(macro, @ticket, actor: escalated_current_user)
171
+
172
+ redirect_to agent_ticket_path(@ticket), notice: "Macro \"#{macro.name}\" applied."
173
+ end
174
+
175
+ def follow
176
+ authorize @ticket, policy_class: Escalated::TicketPolicy
177
+
178
+ if @ticket.followed_by?(escalated_current_user.id)
179
+ @ticket.unfollow(escalated_current_user.id)
180
+ redirect_to agent_ticket_path(@ticket), notice: "Unfollowed ticket."
181
+ else
182
+ @ticket.follow(escalated_current_user.id)
183
+ redirect_to agent_ticket_path(@ticket), notice: "Following ticket."
184
+ end
185
+ end
186
+
187
+ def presence
188
+ user_id = escalated_current_user.id
189
+ user_name = escalated_current_user.respond_to?(:name) ? escalated_current_user.name : escalated_current_user.email
190
+ cache_key = "escalated.presence.#{@ticket.id}.#{user_id}"
191
+
192
+ Rails.cache.write(cache_key, { id: user_id, name: user_name }, expires_in: 30.seconds)
193
+
194
+ # Track active user IDs for this ticket
195
+ list_key = "escalated.presence_list.#{@ticket.id}"
196
+ active_ids = Rails.cache.read(list_key) || []
197
+ active_ids << user_id unless active_ids.include?(user_id)
198
+ Rails.cache.write(list_key, active_ids, expires_in: 2.minutes)
199
+
200
+ # Collect viewers (exclude current user)
201
+ viewers = []
202
+ active_ids.each do |uid|
203
+ next if uid == user_id
204
+
205
+ viewer = Rails.cache.read("escalated.presence.#{@ticket.id}.#{uid}")
206
+ viewers << viewer if viewer
207
+ end
208
+
209
+ render json: { viewers: viewers }
210
+ end
211
+
212
+ def pin
213
+ reply = @ticket.replies.find(params[:reply_id])
214
+
215
+ unless reply.is_internal
216
+ redirect_to agent_ticket_path(@ticket), alert: "Only internal notes can be pinned."
217
+ return
218
+ end
219
+
220
+ reply.update!(is_pinned: !reply.is_pinned)
221
+
222
+ redirect_to agent_ticket_path(@ticket),
223
+ notice: reply.is_pinned ? "Note pinned." : "Note unpinned."
224
+ end
225
+
226
+ private
227
+
228
+ def set_ticket
229
+ @ticket = Escalated::Ticket.find_by!(reference: params[:id])
230
+ rescue ActiveRecord::RecordNotFound
231
+ @ticket = Escalated::Ticket.find(params[:id])
232
+ end
233
+
234
+ def update_params
235
+ params.require(:ticket).permit(:subject, :description)
236
+ end
237
+
238
+ def agent_ticket_path(ticket)
239
+ escalated.agent_ticket_path(ticket)
240
+ end
241
+
242
+ def agent_list
243
+ if Escalated.configuration.user_model.respond_to?(:escalated_agents)
244
+ Escalated.configuration.user_model.escalated_agents.map { |a|
245
+ { id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
246
+ }
247
+ else
248
+ []
249
+ end
250
+ end
251
+
252
+ def ticket_list_json(ticket)
253
+ {
254
+ id: ticket.id,
255
+ reference: ticket.reference,
256
+ subject: ticket.subject,
257
+ status: ticket.status,
258
+ priority: ticket.priority,
259
+ requester: {
260
+ name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email
261
+ },
262
+ assignee: ticket.assignee ? {
263
+ id: ticket.assignee.id,
264
+ name: ticket.assignee.respond_to?(:name) ? ticket.assignee.name : ticket.assignee.email
265
+ } : nil,
266
+ department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
267
+ tags: ticket.tags.map { |t| { id: t.id, name: t.name, color: t.color } },
268
+ sla_breached: ticket.sla_breached,
269
+ created_at: ticket.created_at&.iso8601,
270
+ updated_at: ticket.updated_at&.iso8601
271
+ }
272
+ end
273
+
274
+ def ticket_detail_json(ticket)
275
+ ticket_list_json(ticket).merge(
276
+ description: ticket.description,
277
+ metadata: ticket.metadata,
278
+ sla_policy: ticket.sla_policy ? { id: ticket.sla_policy.id, name: ticket.sla_policy.name } : nil,
279
+ sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
280
+ sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601,
281
+ first_response_at: ticket.first_response_at&.iso8601,
282
+ resolved_at: ticket.resolved_at&.iso8601,
283
+ closed_at: ticket.closed_at&.iso8601,
284
+ reply_count: ticket.replies.count,
285
+ attachment_count: ticket.attachments.count,
286
+ satisfaction_rating: ticket.satisfaction_rating ? {
287
+ id: ticket.satisfaction_rating.id,
288
+ rating: ticket.satisfaction_rating.rating,
289
+ comment: ticket.satisfaction_rating.comment,
290
+ created_at: ticket.satisfaction_rating.created_at&.iso8601
291
+ } : nil,
292
+ pinned_notes: ticket.pinned_notes.includes(:author).map { |n| reply_json(n) }
293
+ )
294
+ end
295
+
296
+ def reply_json(reply)
297
+ {
298
+ id: reply.id,
299
+ body: reply.body,
300
+ is_internal: reply.is_internal,
301
+ is_internal_note: reply.is_internal,
302
+ is_system: reply.is_system,
303
+ is_pinned: reply.respond_to?(:is_pinned) ? reply.is_pinned : false,
304
+ author: reply.author ? {
305
+ id: reply.author.id,
306
+ name: reply.author.respond_to?(:name) ? reply.author.name : reply.author.email,
307
+ is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
308
+ } : { name: "System", is_agent: true },
309
+ attachments: reply.attachments.map { |a|
310
+ { id: a.id, filename: a.filename, size: a.human_size, content_type: a.content_type }
311
+ },
312
+ created_at: reply.created_at&.iso8601
313
+ }
314
+ end
315
+
316
+ def activity_json(activity)
317
+ {
318
+ id: activity.id,
319
+ action: activity.action,
320
+ description: activity.description,
321
+ causer: activity.causer ? {
322
+ name: activity.causer.respond_to?(:name) ? activity.causer.name : activity.causer.email
323
+ } : nil,
324
+ details: activity.details,
325
+ created_at: activity.created_at&.iso8601
326
+ }
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,110 @@
1
+ module Escalated
2
+ class ApplicationController < ActionController::Base
3
+ include Pundit::Authorization
4
+
5
+ protect_from_forgery with: :exception
6
+
7
+ before_action :apply_middleware
8
+ before_action :set_inertia_shared_data
9
+
10
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
11
+ rescue_from ActiveRecord::RecordNotFound, with: :not_found
12
+ rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
13
+
14
+ private
15
+
16
+ def apply_middleware
17
+ Escalated.configuration.middleware.each do |middleware_method|
18
+ send(middleware_method) if respond_to?(middleware_method, true)
19
+ end
20
+ end
21
+
22
+ def set_inertia_shared_data
23
+ inertia_share(
24
+ current_user: current_user_data,
25
+ escalated: {
26
+ route_prefix: Escalated.configuration.route_prefix,
27
+ allow_customer_close: Escalated.configuration.allow_customer_close,
28
+ max_attachments: Escalated.configuration.max_attachments,
29
+ max_attachment_size_kb: Escalated.configuration.max_attachment_size_kb,
30
+ guest_tickets_enabled: Escalated::EscalatedSetting.guest_tickets_enabled?
31
+ },
32
+ flash: {
33
+ success: flash[:success],
34
+ error: flash[:error],
35
+ notice: flash[:notice],
36
+ alert: flash[:alert]
37
+ }
38
+ )
39
+ end
40
+
41
+ def current_user_data
42
+ return nil unless respond_to?(:current_user) && current_user
43
+
44
+ {
45
+ id: current_user.id,
46
+ name: current_user.respond_to?(:name) ? current_user.name : current_user.email,
47
+ email: current_user.email,
48
+ is_agent: current_user.respond_to?(:escalated_agent?) ? current_user.escalated_agent? : false,
49
+ is_admin: current_user.respond_to?(:escalated_admin?) ? current_user.escalated_admin? : false
50
+ }
51
+ end
52
+
53
+ def escalated_current_user
54
+ return nil unless respond_to?(:current_user)
55
+
56
+ current_user
57
+ end
58
+
59
+ def require_agent!
60
+ unless current_user_data&.dig(:is_agent) || current_user_data&.dig(:is_admin)
61
+ redirect_to main_app.root_path, alert: "Access denied."
62
+ end
63
+ end
64
+
65
+ def require_admin!
66
+ unless current_user_data&.dig(:is_admin)
67
+ redirect_to main_app.root_path, alert: "Access denied."
68
+ end
69
+ end
70
+
71
+ def user_not_authorized
72
+ render inertia: "Escalated/Error", props: {
73
+ status: 403,
74
+ message: "You are not authorized to perform this action."
75
+ }, status: :forbidden
76
+ end
77
+
78
+ def not_found
79
+ render inertia: "Escalated/Error", props: {
80
+ status: 404,
81
+ message: "The requested resource was not found."
82
+ }, status: :not_found
83
+ end
84
+
85
+ def unprocessable_entity(exception)
86
+ redirect_back(
87
+ fallback_location: main_app.root_path,
88
+ alert: exception.record.errors.full_messages.join(", ")
89
+ )
90
+ end
91
+
92
+ def paginate(scope, per_page: 25)
93
+ page = (params[:page] || 1).to_i
94
+ per = (params[:per_page] || per_page).to_i
95
+
96
+ total = scope.count
97
+ records = scope.offset((page - 1) * per).limit(per)
98
+
99
+ {
100
+ data: records,
101
+ meta: {
102
+ current_page: page,
103
+ per_page: per,
104
+ total: total,
105
+ total_pages: (total.to_f / per).ceil
106
+ }
107
+ }
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,44 @@
1
+ module Escalated
2
+ module Customer
3
+ class SatisfactionRatingsController < Escalated::ApplicationController
4
+ before_action :set_ticket
5
+
6
+ def create
7
+ unless %w[resolved closed].include?(@ticket.status)
8
+ redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
9
+ alert: "You can only rate resolved or closed tickets."
10
+ return
11
+ end
12
+
13
+ if @ticket.satisfaction_rating.present?
14
+ redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
15
+ alert: "This ticket has already been rated."
16
+ return
17
+ end
18
+
19
+ rating = Escalated::SatisfactionRating.new(
20
+ ticket: @ticket,
21
+ rating: params[:rating].to_i,
22
+ comment: params[:comment],
23
+ rated_by: escalated_current_user
24
+ )
25
+
26
+ if rating.save
27
+ redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
28
+ notice: "Thank you for your feedback!"
29
+ else
30
+ redirect_back fallback_location: escalated.customer_ticket_path(@ticket),
31
+ alert: rating.errors.full_messages.join(", ")
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def set_ticket
38
+ @ticket = Escalated::Ticket.find_by!(reference: params[:id])
39
+ rescue ActiveRecord::RecordNotFound
40
+ @ticket = Escalated::Ticket.find(params[:id])
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,169 @@
1
+ module Escalated
2
+ module Customer
3
+ class TicketsController < Escalated::ApplicationController
4
+ before_action :set_ticket, only: [:show, :reply, :close, :reopen]
5
+
6
+ def index
7
+ scope = Escalated::Ticket.where(
8
+ requester: escalated_current_user
9
+ ).recent
10
+
11
+ scope = scope.where(status: params[:status]) if params[:status].present?
12
+ scope = scope.search(params[:search]) if params[:search].present?
13
+
14
+ result = paginate(scope)
15
+
16
+ render inertia: "Escalated/Customer/Index", props: {
17
+ tickets: result[:data].map { |t| ticket_json(t) },
18
+ meta: result[:meta],
19
+ filters: {
20
+ status: params[:status],
21
+ search: params[:search]
22
+ }
23
+ }
24
+ end
25
+
26
+ def create
27
+ render inertia: "Escalated/Customer/Create", props: {
28
+ departments: Escalated::Department.active.ordered.map { |d|
29
+ { id: d.id, name: d.name }
30
+ },
31
+ priorities: Escalated::Ticket.priorities.keys,
32
+ default_priority: Escalated.configuration.default_priority.to_s
33
+ }
34
+ end
35
+
36
+ def store
37
+ ticket = Services::TicketService.create(
38
+ subject: ticket_params[:subject],
39
+ description: ticket_params[:description],
40
+ priority: ticket_params[:priority] || Escalated.configuration.default_priority,
41
+ department_id: ticket_params[:department_id],
42
+ requester: escalated_current_user,
43
+ metadata: {}
44
+ )
45
+
46
+ if ticket_params[:attachments].present?
47
+ Services::AttachmentService.attach(ticket, ticket_params[:attachments])
48
+ end
49
+
50
+ redirect_to customer_ticket_path(ticket), notice: "Ticket created successfully."
51
+ rescue Services::AttachmentService::TooManyAttachmentsError,
52
+ Services::AttachmentService::FileTooLargeError,
53
+ Services::AttachmentService::InvalidFileTypeError => e
54
+ redirect_back fallback_location: new_customer_ticket_path, alert: e.message
55
+ end
56
+
57
+ def show
58
+ authorize @ticket, policy_class: Escalated::TicketPolicy
59
+
60
+ replies = @ticket.replies
61
+ .public_replies
62
+ .chronological
63
+ .includes(:author, :attachments)
64
+
65
+ render inertia: "Escalated/Customer/Show", props: {
66
+ ticket: ticket_json(@ticket),
67
+ replies: replies.map { |r| reply_json(r) },
68
+ can_close: Escalated.configuration.allow_customer_close && @ticket.open?,
69
+ can_reopen: %w[resolved closed].include?(@ticket.status)
70
+ }
71
+ end
72
+
73
+ def reply
74
+ authorize @ticket, policy_class: Escalated::TicketPolicy
75
+
76
+ reply = Services::TicketService.reply(@ticket, {
77
+ body: params[:body],
78
+ author: escalated_current_user,
79
+ is_internal: false
80
+ })
81
+
82
+ if params[:attachments].present?
83
+ Services::AttachmentService.attach(reply, params[:attachments])
84
+ end
85
+
86
+ redirect_to customer_ticket_path(@ticket), notice: "Reply sent."
87
+ rescue Services::AttachmentService::TooManyAttachmentsError,
88
+ Services::AttachmentService::FileTooLargeError,
89
+ Services::AttachmentService::InvalidFileTypeError => e
90
+ redirect_to customer_ticket_path(@ticket), alert: e.message
91
+ end
92
+
93
+ def close
94
+ authorize @ticket, policy_class: Escalated::TicketPolicy
95
+
96
+ unless Escalated.configuration.allow_customer_close
97
+ redirect_to customer_ticket_path(@ticket), alert: "Customers cannot close tickets."
98
+ return
99
+ end
100
+
101
+ Services::TicketService.close(@ticket, actor: escalated_current_user)
102
+ redirect_to customer_ticket_path(@ticket), notice: "Ticket closed."
103
+ end
104
+
105
+ def reopen
106
+ authorize @ticket, policy_class: Escalated::TicketPolicy
107
+
108
+ Services::TicketService.reopen(@ticket, actor: escalated_current_user)
109
+ redirect_to customer_ticket_path(@ticket), notice: "Ticket reopened."
110
+ end
111
+
112
+ private
113
+
114
+ def set_ticket
115
+ @ticket = Escalated::Ticket.find(params[:id])
116
+ end
117
+
118
+ def ticket_params
119
+ params.require(:ticket).permit(:subject, :description, :priority, :department_id, attachments: [])
120
+ end
121
+
122
+ def ticket_json(ticket)
123
+ {
124
+ id: ticket.id,
125
+ reference: ticket.reference,
126
+ subject: ticket.subject,
127
+ description: ticket.description,
128
+ status: ticket.status,
129
+ priority: ticket.priority,
130
+ department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
131
+ created_at: ticket.created_at&.iso8601,
132
+ updated_at: ticket.updated_at&.iso8601,
133
+ resolved_at: ticket.resolved_at&.iso8601,
134
+ reply_count: ticket.replies.public_replies.count,
135
+ satisfaction_rating: ticket.satisfaction_rating ? {
136
+ id: ticket.satisfaction_rating.id,
137
+ rating: ticket.satisfaction_rating.rating,
138
+ comment: ticket.satisfaction_rating.comment,
139
+ created_at: ticket.satisfaction_rating.created_at&.iso8601
140
+ } : nil
141
+ }
142
+ end
143
+
144
+ def reply_json(reply)
145
+ {
146
+ id: reply.id,
147
+ body: reply.body,
148
+ author: {
149
+ name: reply.author.respond_to?(:name) ? reply.author.name : reply.author&.email,
150
+ is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
151
+ },
152
+ attachments: reply.attachments.map { |a|
153
+ { id: a.id, filename: a.filename, size: a.human_size, url: Services::AttachmentService.url_for(a) }
154
+ },
155
+ created_at: reply.created_at&.iso8601,
156
+ is_system: reply.is_system
157
+ }
158
+ end
159
+
160
+ def customer_ticket_path(ticket)
161
+ escalated.customer_ticket_path(ticket)
162
+ end
163
+
164
+ def new_customer_ticket_path
165
+ escalated.new_customer_ticket_path
166
+ end
167
+ end
168
+ end
169
+ end