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,299 @@
1
+ module Escalated
2
+ module Admin
3
+ class TicketsController < Escalated::ApplicationController
4
+ before_action :require_admin!
5
+ before_action :set_ticket, only: [:show, :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
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/Admin/Tickets/Index", 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
+ replies = @ticket.replies.chronological.includes(:author, :attachments)
52
+ activities = @ticket.activities.reverse_chronological.limit(50)
53
+
54
+ render inertia: "Escalated/Admin/Tickets/Show", props: {
55
+ ticket: ticket_detail_json(@ticket),
56
+ replies: replies.map { |r| reply_json(r) },
57
+ activities: activities.map { |a| activity_json(a) },
58
+ departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
59
+ agents: agent_list,
60
+ tags: Escalated::Tag.ordered.map { |t| { id: t.id, name: t.name, color: t.color } },
61
+ canned_responses: Escalated::CannedResponse.for_user(escalated_current_user.id).ordered.map { |c|
62
+ { id: c.id, title: c.title, body: c.body, shortcode: c.shortcode }
63
+ },
64
+ macros: Escalated::Macro.for_agent(escalated_current_user.id).ordered.map { |m|
65
+ { id: m.id, name: m.name, description: m.description, actions: m.actions }
66
+ },
67
+ is_following: @ticket.followed_by?(escalated_current_user.id),
68
+ followers_count: @ticket.followers.count,
69
+ statuses: Escalated::Ticket.statuses.keys,
70
+ priorities: Escalated::Ticket.priorities.keys
71
+ }
72
+ end
73
+
74
+ def reply
75
+ reply = Services::TicketService.reply(@ticket, {
76
+ body: params[:body],
77
+ author: escalated_current_user,
78
+ is_internal: false
79
+ })
80
+
81
+ if params[:attachments].present?
82
+ Services::AttachmentService.attach(reply, params[:attachments])
83
+ end
84
+
85
+ redirect_to admin_ticket_path(@ticket), notice: "Reply sent."
86
+ end
87
+
88
+ def note
89
+ Services::TicketService.reply(@ticket, {
90
+ body: params[:body],
91
+ author: escalated_current_user,
92
+ is_internal: true
93
+ })
94
+
95
+ redirect_to admin_ticket_path(@ticket), notice: "Internal note added."
96
+ end
97
+
98
+ def assign
99
+ if params[:agent_id].present?
100
+ agent = Escalated.configuration.user_model.find(params[:agent_id])
101
+ Services::AssignmentService.assign(@ticket, agent, actor: escalated_current_user)
102
+ redirect_to admin_ticket_path(@ticket), notice: "Ticket assigned to #{agent.respond_to?(:name) ? agent.name : agent.email}."
103
+ else
104
+ Services::AssignmentService.unassign(@ticket, actor: escalated_current_user)
105
+ redirect_to admin_ticket_path(@ticket), notice: "Ticket unassigned."
106
+ end
107
+ end
108
+
109
+ def status
110
+ Services::TicketService.transition_status(
111
+ @ticket,
112
+ params[:status],
113
+ actor: escalated_current_user,
114
+ note: params[:note]
115
+ )
116
+
117
+ redirect_to admin_ticket_path(@ticket), notice: "Status updated to #{params[:status].humanize}."
118
+ end
119
+
120
+ def priority
121
+ Services::TicketService.change_priority(@ticket, params[:priority], actor: escalated_current_user)
122
+ redirect_to admin_ticket_path(@ticket), notice: "Priority updated to #{params[:priority]}."
123
+ end
124
+
125
+ def tags
126
+ if params[:add_tag_ids].present?
127
+ Services::TicketService.add_tags(@ticket, params[:add_tag_ids], actor: escalated_current_user)
128
+ end
129
+
130
+ if params[:remove_tag_ids].present?
131
+ Services::TicketService.remove_tags(@ticket, params[:remove_tag_ids], actor: escalated_current_user)
132
+ end
133
+
134
+ redirect_to admin_ticket_path(@ticket), notice: "Tags updated."
135
+ end
136
+
137
+ def department
138
+ dept = Escalated::Department.find(params[:department_id])
139
+ Services::TicketService.change_department(@ticket, dept, actor: escalated_current_user)
140
+ redirect_to admin_ticket_path(@ticket), notice: "Department changed to #{dept.name}."
141
+ end
142
+
143
+ def apply_macro
144
+ macro = Escalated::Macro.for_agent(escalated_current_user.id).find(params[:macro_id])
145
+ Services::MacroService.apply(macro, @ticket, actor: escalated_current_user)
146
+
147
+ redirect_to admin_ticket_path(@ticket), notice: "Macro \"#{macro.name}\" applied."
148
+ end
149
+
150
+ def follow
151
+ if @ticket.followed_by?(escalated_current_user.id)
152
+ @ticket.unfollow(escalated_current_user.id)
153
+ redirect_to admin_ticket_path(@ticket), notice: "Unfollowed ticket."
154
+ else
155
+ @ticket.follow(escalated_current_user.id)
156
+ redirect_to admin_ticket_path(@ticket), notice: "Following ticket."
157
+ end
158
+ end
159
+
160
+ def presence
161
+ user_id = escalated_current_user.id
162
+ user_name = escalated_current_user.respond_to?(:name) ? escalated_current_user.name : escalated_current_user.email
163
+ cache_key = "escalated.presence.#{@ticket.id}.#{user_id}"
164
+
165
+ Rails.cache.write(cache_key, { id: user_id, name: user_name }, expires_in: 30.seconds)
166
+
167
+ # Track active user IDs for this ticket
168
+ list_key = "escalated.presence_list.#{@ticket.id}"
169
+ active_ids = Rails.cache.read(list_key) || []
170
+ active_ids << user_id unless active_ids.include?(user_id)
171
+ Rails.cache.write(list_key, active_ids, expires_in: 2.minutes)
172
+
173
+ # Collect viewers (exclude current user)
174
+ viewers = []
175
+ active_ids.each do |uid|
176
+ next if uid == user_id
177
+
178
+ viewer = Rails.cache.read("escalated.presence.#{@ticket.id}.#{uid}")
179
+ viewers << viewer if viewer
180
+ end
181
+
182
+ render json: { viewers: viewers }
183
+ end
184
+
185
+ def pin
186
+ reply = @ticket.replies.find(params[:reply_id])
187
+
188
+ unless reply.is_internal
189
+ redirect_to admin_ticket_path(@ticket), alert: "Only internal notes can be pinned."
190
+ return
191
+ end
192
+
193
+ reply.update!(is_pinned: !reply.is_pinned)
194
+
195
+ redirect_to admin_ticket_path(@ticket),
196
+ notice: reply.is_pinned ? "Note pinned." : "Note unpinned."
197
+ end
198
+
199
+ private
200
+
201
+ def set_ticket
202
+ @ticket = Escalated::Ticket.find_by!(reference: params[:id])
203
+ rescue ActiveRecord::RecordNotFound
204
+ @ticket = Escalated::Ticket.find(params[:id])
205
+ end
206
+
207
+ def admin_ticket_path(ticket)
208
+ escalated.admin_ticket_path(ticket)
209
+ end
210
+
211
+ def agent_list
212
+ if Escalated.configuration.user_model.respond_to?(:escalated_agents)
213
+ Escalated.configuration.user_model.escalated_agents.map { |a|
214
+ { id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
215
+ }
216
+ else
217
+ []
218
+ end
219
+ end
220
+
221
+ def ticket_list_json(ticket)
222
+ {
223
+ id: ticket.id,
224
+ reference: ticket.reference,
225
+ subject: ticket.subject,
226
+ status: ticket.status,
227
+ priority: ticket.priority,
228
+ requester: {
229
+ name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email
230
+ },
231
+ assignee: ticket.assignee ? {
232
+ id: ticket.assignee.id,
233
+ name: ticket.assignee.respond_to?(:name) ? ticket.assignee.name : ticket.assignee.email
234
+ } : nil,
235
+ department: ticket.department ? { id: ticket.department.id, name: ticket.department.name } : nil,
236
+ tags: ticket.tags.map { |t| { id: t.id, name: t.name, color: t.color } },
237
+ sla_breached: ticket.sla_breached,
238
+ created_at: ticket.created_at&.iso8601,
239
+ updated_at: ticket.updated_at&.iso8601
240
+ }
241
+ end
242
+
243
+ def ticket_detail_json(ticket)
244
+ ticket_list_json(ticket).merge(
245
+ description: ticket.description,
246
+ metadata: ticket.metadata,
247
+ sla_policy: ticket.sla_policy ? { id: ticket.sla_policy.id, name: ticket.sla_policy.name } : nil,
248
+ sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
249
+ sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601,
250
+ first_response_at: ticket.first_response_at&.iso8601,
251
+ resolved_at: ticket.resolved_at&.iso8601,
252
+ closed_at: ticket.closed_at&.iso8601,
253
+ reply_count: ticket.replies.count,
254
+ attachment_count: ticket.attachments.count,
255
+ satisfaction_rating: ticket.satisfaction_rating ? {
256
+ id: ticket.satisfaction_rating.id,
257
+ rating: ticket.satisfaction_rating.rating,
258
+ comment: ticket.satisfaction_rating.comment,
259
+ created_at: ticket.satisfaction_rating.created_at&.iso8601
260
+ } : nil,
261
+ pinned_notes: ticket.pinned_notes.includes(:author).map { |n| reply_json(n) }
262
+ )
263
+ end
264
+
265
+ def reply_json(reply)
266
+ {
267
+ id: reply.id,
268
+ body: reply.body,
269
+ is_internal: reply.is_internal,
270
+ is_internal_note: reply.is_internal,
271
+ is_system: reply.is_system,
272
+ is_pinned: reply.respond_to?(:is_pinned) ? reply.is_pinned : false,
273
+ author: reply.author ? {
274
+ id: reply.author.id,
275
+ name: reply.author.respond_to?(:name) ? reply.author.name : reply.author.email,
276
+ is_agent: reply.author.respond_to?(:escalated_agent?) ? reply.author.escalated_agent? : false
277
+ } : { name: "System", is_agent: true },
278
+ attachments: reply.attachments.map { |a|
279
+ { id: a.id, filename: a.filename, size: a.human_size, content_type: a.content_type }
280
+ },
281
+ created_at: reply.created_at&.iso8601
282
+ }
283
+ end
284
+
285
+ def activity_json(activity)
286
+ {
287
+ id: activity.id,
288
+ action: activity.action,
289
+ description: activity.description,
290
+ causer: activity.causer ? {
291
+ name: activity.causer.respond_to?(:name) ? activity.causer.name : activity.causer.email
292
+ } : nil,
293
+ details: activity.details,
294
+ created_at: activity.created_at&.iso8601
295
+ }
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,42 @@
1
+ module Escalated
2
+ module Agent
3
+ class BulkActionsController < Escalated::ApplicationController
4
+ before_action :require_agent!
5
+
6
+ def create
7
+ ticket_ids = params[:ticket_ids]
8
+ action = params[:action]
9
+ value = params[:value]
10
+ success_count = 0
11
+
12
+ tickets = Escalated::Ticket.where(id: ticket_ids)
13
+
14
+ tickets.each do |ticket|
15
+ begin
16
+ case action.to_s
17
+ when "status"
18
+ Services::TicketService.transition_status(ticket, value, actor: escalated_current_user)
19
+ when "priority"
20
+ Services::TicketService.change_priority(ticket, value, actor: escalated_current_user)
21
+ when "assign"
22
+ agent = Escalated.configuration.user_model.find(value)
23
+ Services::AssignmentService.assign(ticket, agent, actor: escalated_current_user)
24
+ when "tag"
25
+ Services::TicketService.add_tags(ticket, Array(value), actor: escalated_current_user)
26
+ when "close"
27
+ Services::TicketService.close(ticket, actor: escalated_current_user)
28
+ when "delete"
29
+ ticket.destroy!
30
+ end
31
+ success_count += 1
32
+ rescue StandardError => e
33
+ Rails.logger.warn("[Escalated::BulkActions] Failed to #{action} ticket ##{ticket.id}: #{e.message}")
34
+ end
35
+ end
36
+
37
+ redirect_back fallback_location: escalated.agent_tickets_path,
38
+ notice: "#{success_count} ticket(s) updated."
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,94 @@
1
+ module Escalated
2
+ module Agent
3
+ class DashboardController < Escalated::ApplicationController
4
+ before_action :require_agent!
5
+
6
+ def index
7
+ my_tickets = Escalated::Ticket.assigned_to(escalated_current_user.id)
8
+ all_open = Escalated::Ticket.by_open
9
+
10
+ stats = {
11
+ my_open: my_tickets.by_open.count,
12
+ my_waiting: my_tickets.where(status: :waiting_on_customer).count,
13
+ unassigned: all_open.unassigned.count,
14
+ total_open: all_open.count,
15
+ breached_sla: all_open.breached_sla.count,
16
+ resolved_today: Escalated::Ticket.where(
17
+ status: :resolved,
18
+ resolved_at: Time.current.beginning_of_day..Time.current.end_of_day
19
+ ).count,
20
+ avg_first_response: calculate_avg_first_response,
21
+ avg_resolution_time: calculate_avg_resolution_time,
22
+ avg_csat_rating: calculate_avg_csat_rating,
23
+ total_ratings: Escalated::SatisfactionRating.count,
24
+ resolved_with_rating_count: Escalated::SatisfactionRating
25
+ .joins(:ticket)
26
+ .where("#{Escalated.table_name('tickets')}.status" => [:resolved, :closed])
27
+ .count
28
+ }
29
+
30
+ recent_tickets = my_tickets.by_open.recent.limit(10)
31
+ unassigned_tickets = all_open.unassigned.recent.limit(10)
32
+ breached_tickets = all_open.breached_sla.recent.limit(10)
33
+
34
+ render inertia: "Escalated/Agent/Dashboard", props: {
35
+ stats: stats,
36
+ recent_tickets: recent_tickets.map { |t| ticket_summary_json(t) },
37
+ unassigned_tickets: unassigned_tickets.map { |t| ticket_summary_json(t) },
38
+ breached_tickets: breached_tickets.map { |t| ticket_summary_json(t) },
39
+ sla_stats: Services::SlaService.stats
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def calculate_avg_first_response
46
+ tickets = Escalated::Ticket
47
+ .where.not(first_response_at: nil)
48
+ .where(created_at: 30.days.ago..Time.current)
49
+
50
+ return 0 if tickets.empty?
51
+
52
+ total_seconds = tickets.sum { |t| (t.first_response_at - t.created_at).to_f }
53
+ avg_seconds = total_seconds / tickets.count
54
+ (avg_seconds / 3600.0).round(1) # Return in hours
55
+ end
56
+
57
+ def calculate_avg_resolution_time
58
+ tickets = Escalated::Ticket
59
+ .where.not(resolved_at: nil)
60
+ .where(created_at: 30.days.ago..Time.current)
61
+
62
+ return 0 if tickets.empty?
63
+
64
+ total_seconds = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
65
+ avg_seconds = total_seconds / tickets.count
66
+ (avg_seconds / 3600.0).round(1) # Return in hours
67
+ end
68
+
69
+ def calculate_avg_csat_rating
70
+ ratings = Escalated::SatisfactionRating.all
71
+ return 0.0 if ratings.empty?
72
+
73
+ (ratings.average(:rating).to_f).round(2)
74
+ end
75
+
76
+ def ticket_summary_json(ticket)
77
+ {
78
+ id: ticket.id,
79
+ reference: ticket.reference,
80
+ subject: ticket.subject,
81
+ status: ticket.status,
82
+ priority: ticket.priority,
83
+ requester_name: ticket.requester.respond_to?(:name) ? ticket.requester.name : ticket.requester&.email,
84
+ department: ticket.department&.name,
85
+ created_at: ticket.created_at&.iso8601,
86
+ updated_at: ticket.updated_at&.iso8601,
87
+ sla_breached: ticket.sla_breached,
88
+ sla_first_response_due_at: ticket.sla_first_response_due_at&.iso8601,
89
+ sla_resolution_due_at: ticket.sla_resolution_due_at&.iso8601
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end