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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +302 -0
- data/app/controllers/escalated/admin/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/admin/canned_responses_controller.rb +73 -0
- data/app/controllers/escalated/admin/departments_controller.rb +135 -0
- data/app/controllers/escalated/admin/escalation_rules_controller.rb +121 -0
- data/app/controllers/escalated/admin/macros_controller.rb +73 -0
- data/app/controllers/escalated/admin/reports_controller.rb +152 -0
- data/app/controllers/escalated/admin/settings_controller.rb +111 -0
- data/app/controllers/escalated/admin/sla_policies_controller.rb +109 -0
- data/app/controllers/escalated/admin/tags_controller.rb +67 -0
- data/app/controllers/escalated/admin/tickets_controller.rb +299 -0
- data/app/controllers/escalated/agent/bulk_actions_controller.rb +42 -0
- data/app/controllers/escalated/agent/dashboard_controller.rb +94 -0
- data/app/controllers/escalated/agent/tickets_controller.rb +330 -0
- data/app/controllers/escalated/application_controller.rb +110 -0
- data/app/controllers/escalated/customer/satisfaction_ratings_controller.rb +44 -0
- data/app/controllers/escalated/customer/tickets_controller.rb +169 -0
- data/app/controllers/escalated/guest/tickets_controller.rb +231 -0
- data/app/controllers/escalated/inbound_controller.rb +79 -0
- data/app/jobs/escalated/check_sla_job.rb +36 -0
- data/app/jobs/escalated/close_resolved_job.rb +51 -0
- data/app/jobs/escalated/evaluate_escalations_job.rb +24 -0
- data/app/jobs/escalated/poll_imap_job.rb +74 -0
- data/app/jobs/escalated/purge_activities_job.rb +24 -0
- data/app/mailers/escalated/application_mailer.rb +6 -0
- data/app/mailers/escalated/ticket_mailer.rb +93 -0
- data/app/models/escalated/application_record.rb +5 -0
- data/app/models/escalated/attachment.rb +46 -0
- data/app/models/escalated/canned_response.rb +45 -0
- data/app/models/escalated/department.rb +43 -0
- data/app/models/escalated/escalated_setting.rb +43 -0
- data/app/models/escalated/escalation_rule.rb +96 -0
- data/app/models/escalated/inbound_email.rb +60 -0
- data/app/models/escalated/macro.rb +18 -0
- data/app/models/escalated/reply.rb +42 -0
- data/app/models/escalated/satisfaction_rating.rb +21 -0
- data/app/models/escalated/sla_policy.rb +54 -0
- data/app/models/escalated/tag.rb +28 -0
- data/app/models/escalated/ticket.rb +166 -0
- data/app/models/escalated/ticket_activity.rb +60 -0
- data/app/policies/escalated/canned_response_policy.rb +40 -0
- data/app/policies/escalated/department_policy.rb +36 -0
- data/app/policies/escalated/escalation_rule_policy.rb +36 -0
- data/app/policies/escalated/sla_policy_policy.rb +36 -0
- data/app/policies/escalated/tag_policy.rb +36 -0
- data/app/policies/escalated/ticket_policy.rb +111 -0
- data/config/routes.rb +81 -0
- data/db/migrate/001_create_escalated_departments.rb +18 -0
- data/db/migrate/002_create_escalated_sla_policies.rb +23 -0
- data/db/migrate/003_create_escalated_tags.rb +15 -0
- data/db/migrate/004_create_escalated_tickets.rb +48 -0
- data/db/migrate/005_create_escalated_replies.rb +21 -0
- data/db/migrate/006_create_escalated_attachments.rb +17 -0
- data/db/migrate/007_create_escalated_ticket_tags.rb +13 -0
- data/db/migrate/008_create_escalated_support_tables.rb +49 -0
- data/db/migrate/009_create_escalated_ticket_activities.rb +20 -0
- data/db/migrate/010_create_escalated_settings.rb +29 -0
- data/db/migrate/011_add_guest_fields_to_escalated_tickets.rb +28 -0
- data/db/migrate/012_create_escalated_inbound_emails.rb +30 -0
- data/db/migrate/013_create_escalated_macros.rb +18 -0
- data/db/migrate/014_create_escalated_ticket_followers.rb +18 -0
- data/db/migrate/015_create_escalated_satisfaction_ratings.rb +21 -0
- data/db/migrate/016_add_is_pinned_to_escalated_replies.rb +6 -0
- data/lib/escalated/configuration.rb +111 -0
- data/lib/escalated/drivers/cloud_driver.rb +134 -0
- data/lib/escalated/drivers/hosted_api_client.rb +166 -0
- data/lib/escalated/drivers/local_driver.rb +341 -0
- data/lib/escalated/drivers/synced_driver.rb +124 -0
- data/lib/escalated/engine.rb +45 -0
- data/lib/escalated/mail/adapters/base_adapter.rb +60 -0
- data/lib/escalated/mail/adapters/imap_adapter.rb +209 -0
- data/lib/escalated/mail/adapters/mailgun_adapter.rb +93 -0
- data/lib/escalated/mail/adapters/postmark_adapter.rb +94 -0
- data/lib/escalated/mail/adapters/ses_adapter.rb +179 -0
- data/lib/escalated/mail/inbound_message.rb +78 -0
- data/lib/escalated/manager.rb +33 -0
- data/lib/escalated/services/assignment_service.rb +85 -0
- data/lib/escalated/services/attachment_service.rb +110 -0
- data/lib/escalated/services/escalation_service.rb +159 -0
- data/lib/escalated/services/inbound_email_service.rb +255 -0
- data/lib/escalated/services/macro_service.rb +49 -0
- data/lib/escalated/services/notification_service.rb +157 -0
- data/lib/escalated/services/sla_service.rb +203 -0
- data/lib/escalated/services/ticket_service.rb +113 -0
- data/lib/escalated.rb +25 -0
- data/lib/generators/escalated/install_generator.rb +75 -0
- data/lib/generators/escalated/templates/initializer.rb +89 -0
- metadata +227 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Escalated
|
|
6
|
+
module Services
|
|
7
|
+
class NotificationService
|
|
8
|
+
class << self
|
|
9
|
+
def dispatch(event, payload = {})
|
|
10
|
+
send_webhook(event, payload) if webhook_configured?
|
|
11
|
+
notify_followers(event, payload) if should_notify_followers?(event)
|
|
12
|
+
instrument_event(event, payload)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def send_webhook(event, payload)
|
|
16
|
+
return unless webhook_configured?
|
|
17
|
+
|
|
18
|
+
webhook_payload = build_webhook_payload(event, payload)
|
|
19
|
+
|
|
20
|
+
Thread.new do
|
|
21
|
+
begin
|
|
22
|
+
uri = URI.parse(Escalated.configuration.webhook_url)
|
|
23
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
24
|
+
http.use_ssl = uri.scheme == "https"
|
|
25
|
+
http.open_timeout = 10
|
|
26
|
+
http.read_timeout = 10
|
|
27
|
+
|
|
28
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
29
|
+
request["Content-Type"] = "application/json"
|
|
30
|
+
request["User-Agent"] = "Escalated-Webhook/0.1.0"
|
|
31
|
+
request["X-Escalated-Event"] = event.to_s
|
|
32
|
+
request["X-Escalated-Signature"] = compute_signature(webhook_payload)
|
|
33
|
+
request.body = webhook_payload.to_json
|
|
34
|
+
|
|
35
|
+
response = http.request(request)
|
|
36
|
+
|
|
37
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
Rails.logger.warn(
|
|
39
|
+
"[Escalated::NotificationService] Webhook returned #{response.code} for event #{event}"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Rails.logger.error(
|
|
44
|
+
"[Escalated::NotificationService] Webhook failed for event #{event}: #{e.message}"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def webhook_configured?
|
|
53
|
+
Escalated.configuration.webhook_url.present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_webhook_payload(event, payload)
|
|
57
|
+
data = {
|
|
58
|
+
event: event.to_s,
|
|
59
|
+
timestamp: Time.current.iso8601,
|
|
60
|
+
data: {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if payload[:ticket]
|
|
64
|
+
ticket = payload[:ticket]
|
|
65
|
+
data[:data][:ticket] = {
|
|
66
|
+
id: ticket.respond_to?(:id) ? ticket.id : nil,
|
|
67
|
+
reference: ticket.respond_to?(:reference) ? ticket.reference : nil,
|
|
68
|
+
subject: ticket.respond_to?(:subject) ? ticket.subject : nil,
|
|
69
|
+
status: ticket.respond_to?(:status) ? ticket.status : nil,
|
|
70
|
+
priority: ticket.respond_to?(:priority) ? ticket.priority : nil
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if payload[:reply]
|
|
75
|
+
reply = payload[:reply]
|
|
76
|
+
data[:data][:reply] = {
|
|
77
|
+
id: reply.respond_to?(:id) ? reply.id : nil,
|
|
78
|
+
is_internal: reply.respond_to?(:is_internal) ? reply.is_internal : nil
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if payload[:agent]
|
|
83
|
+
agent = payload[:agent]
|
|
84
|
+
data[:data][:agent] = {
|
|
85
|
+
id: agent.respond_to?(:id) ? agent.id : nil,
|
|
86
|
+
email: agent.respond_to?(:email) ? agent.email : nil
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Include any extra scalar values
|
|
91
|
+
payload.each do |key, value|
|
|
92
|
+
next if [:ticket, :reply, :agent, :rule, :recipients].include?(key)
|
|
93
|
+
data[:data][key] = value if value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
data
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def compute_signature(payload)
|
|
100
|
+
key = Escalated.configuration.hosted_api_key || "escalated-webhook-secret"
|
|
101
|
+
OpenSSL::HMAC.hexdigest("SHA256", key, payload.to_json)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def should_notify_followers?(event)
|
|
105
|
+
[:reply_added, :status_changed, :ticket_assigned, :priority_changed].include?(event)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def notify_followers(event, payload)
|
|
109
|
+
ticket = payload[:ticket]
|
|
110
|
+
return unless ticket.respond_to?(:followers)
|
|
111
|
+
|
|
112
|
+
reply = payload[:reply]
|
|
113
|
+
actor = payload[:agent]
|
|
114
|
+
|
|
115
|
+
ticket.followers.each do |follower|
|
|
116
|
+
# Skip the actor (person who performed the action)
|
|
117
|
+
next if actor && follower.id == actor.id
|
|
118
|
+
# Skip the reply author
|
|
119
|
+
next if reply && reply.respond_to?(:author) && reply.author == follower
|
|
120
|
+
# Skip the assignee (they already get notified separately)
|
|
121
|
+
next if ticket.respond_to?(:assigned_to) && ticket.assigned_to == follower.id
|
|
122
|
+
# Skip the requester (they already get notified separately)
|
|
123
|
+
next if ticket.respond_to?(:requester) && ticket.requester == follower
|
|
124
|
+
|
|
125
|
+
if Escalated.configuration.notification_channels.include?(:email)
|
|
126
|
+
begin
|
|
127
|
+
case event
|
|
128
|
+
when :reply_added
|
|
129
|
+
Escalated::TicketMailer.reply_received(ticket, reply).deliver_later
|
|
130
|
+
when :status_changed
|
|
131
|
+
Escalated::TicketMailer.status_changed(ticket).deliver_later
|
|
132
|
+
when :ticket_assigned
|
|
133
|
+
Escalated::TicketMailer.ticket_assigned(ticket).deliver_later
|
|
134
|
+
when :priority_changed
|
|
135
|
+
# Priority change notification to followers
|
|
136
|
+
Escalated::TicketMailer.status_changed(ticket).deliver_later
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
Rails.logger.warn(
|
|
140
|
+
"[Escalated::NotificationService] Follower notification failed for #{follower.id}: #{e.message}"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
Rails.logger.warn(
|
|
147
|
+
"[Escalated::NotificationService] Follower notification dispatch failed: #{e.message}"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def instrument_event(event, payload)
|
|
152
|
+
ActiveSupport::Notifications.instrument("escalated.notification.#{event}", payload)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Services
|
|
3
|
+
class SlaService
|
|
4
|
+
class << self
|
|
5
|
+
def attach_policy(ticket, policy = nil)
|
|
6
|
+
return unless Escalated.configuration.sla_enabled?
|
|
7
|
+
|
|
8
|
+
policy ||= find_policy_for(ticket)
|
|
9
|
+
return unless policy
|
|
10
|
+
|
|
11
|
+
ticket.update!(
|
|
12
|
+
sla_policy_id: policy.id,
|
|
13
|
+
sla_first_response_due_at: calculate_due_date(policy.first_response_hours_for(ticket.priority)),
|
|
14
|
+
sla_resolution_due_at: calculate_due_date(policy.resolution_hours_for(ticket.priority))
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_breaches
|
|
19
|
+
return unless Escalated.configuration.sla_enabled?
|
|
20
|
+
|
|
21
|
+
breached_tickets = []
|
|
22
|
+
|
|
23
|
+
# Check first response breaches
|
|
24
|
+
Escalated::Ticket
|
|
25
|
+
.by_open
|
|
26
|
+
.where(sla_breached: false)
|
|
27
|
+
.where.not(sla_first_response_due_at: nil)
|
|
28
|
+
.where(first_response_at: nil)
|
|
29
|
+
.where("sla_first_response_due_at < ?", Time.current)
|
|
30
|
+
.find_each do |ticket|
|
|
31
|
+
mark_breached(ticket, :first_response)
|
|
32
|
+
breached_tickets << ticket
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check resolution breaches
|
|
36
|
+
Escalated::Ticket
|
|
37
|
+
.by_open
|
|
38
|
+
.where(sla_breached: false)
|
|
39
|
+
.where.not(sla_resolution_due_at: nil)
|
|
40
|
+
.where(resolved_at: nil)
|
|
41
|
+
.where("sla_resolution_due_at < ?", Time.current)
|
|
42
|
+
.find_each do |ticket|
|
|
43
|
+
mark_breached(ticket, :resolution)
|
|
44
|
+
breached_tickets << ticket
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
breached_tickets
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_warnings
|
|
51
|
+
return unless Escalated.configuration.sla_enabled?
|
|
52
|
+
|
|
53
|
+
warning_tickets = []
|
|
54
|
+
|
|
55
|
+
# First response warnings (1 hour before breach)
|
|
56
|
+
Escalated::Ticket
|
|
57
|
+
.by_open
|
|
58
|
+
.where(sla_breached: false)
|
|
59
|
+
.where.not(sla_first_response_due_at: nil)
|
|
60
|
+
.where(first_response_at: nil)
|
|
61
|
+
.where("sla_first_response_due_at BETWEEN ? AND ?", Time.current, 1.hour.from_now)
|
|
62
|
+
.find_each do |ticket|
|
|
63
|
+
warning_tickets << { ticket: ticket, type: :first_response_warning }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolution warnings (2 hours before breach)
|
|
67
|
+
Escalated::Ticket
|
|
68
|
+
.by_open
|
|
69
|
+
.where(sla_breached: false)
|
|
70
|
+
.where.not(sla_resolution_due_at: nil)
|
|
71
|
+
.where(resolved_at: nil)
|
|
72
|
+
.where("sla_resolution_due_at BETWEEN ? AND ?", Time.current, 2.hours.from_now)
|
|
73
|
+
.find_each do |ticket|
|
|
74
|
+
warning_tickets << { ticket: ticket, type: :resolution_warning }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
warning_tickets
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def calculate_due_date(hours)
|
|
81
|
+
return nil unless hours
|
|
82
|
+
|
|
83
|
+
if Escalated.configuration.business_hours_only?
|
|
84
|
+
calculate_business_hours_due_date(hours)
|
|
85
|
+
else
|
|
86
|
+
Time.current + hours.hours
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recalculate_for_ticket(ticket)
|
|
91
|
+
return unless ticket.sla_policy
|
|
92
|
+
|
|
93
|
+
policy = ticket.sla_policy
|
|
94
|
+
|
|
95
|
+
updates = {}
|
|
96
|
+
unless ticket.first_response_at
|
|
97
|
+
updates[:sla_first_response_due_at] = calculate_due_date(
|
|
98
|
+
policy.first_response_hours_for(ticket.priority)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
unless ticket.resolved_at
|
|
103
|
+
updates[:sla_resolution_due_at] = calculate_due_date(
|
|
104
|
+
policy.resolution_hours_for(ticket.priority)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
ticket.update!(updates) if updates.any?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stats
|
|
112
|
+
return {} unless Escalated.configuration.sla_enabled?
|
|
113
|
+
|
|
114
|
+
total = Escalated::Ticket.where.not(sla_policy_id: nil).count
|
|
115
|
+
breached = Escalated::Ticket.where(sla_breached: true).count
|
|
116
|
+
|
|
117
|
+
responded = Escalated::Ticket.where.not(first_response_at: nil, sla_first_response_due_at: nil)
|
|
118
|
+
on_time_responses = responded.where("first_response_at <= sla_first_response_due_at").count
|
|
119
|
+
|
|
120
|
+
resolved = Escalated::Ticket.where.not(resolved_at: nil, sla_resolution_due_at: nil)
|
|
121
|
+
on_time_resolutions = resolved.where("resolved_at <= sla_resolution_due_at").count
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
total_with_sla: total,
|
|
125
|
+
total_breached: breached,
|
|
126
|
+
breach_rate: total > 0 ? (breached.to_f / total * 100).round(1) : 0,
|
|
127
|
+
first_response_on_time: on_time_responses,
|
|
128
|
+
first_response_on_time_rate: responded.count > 0 ? (on_time_responses.to_f / responded.count * 100).round(1) : 0,
|
|
129
|
+
resolution_on_time: on_time_resolutions,
|
|
130
|
+
resolution_on_time_rate: resolved.count > 0 ? (on_time_resolutions.to_f / resolved.count * 100).round(1) : 0
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def find_policy_for(ticket)
|
|
137
|
+
if ticket.department&.default_sla_policy_id.present?
|
|
138
|
+
Escalated::SlaPolicy.find_by(id: ticket.department.default_sla_policy_id)
|
|
139
|
+
else
|
|
140
|
+
Escalated::SlaPolicy.default_policy.first
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def mark_breached(ticket, breach_type)
|
|
145
|
+
ActiveRecord::Base.transaction do
|
|
146
|
+
ticket.update!(sla_breached: true)
|
|
147
|
+
|
|
148
|
+
ticket.activities.create!(
|
|
149
|
+
action: "sla_breached",
|
|
150
|
+
causer: nil,
|
|
151
|
+
details: { breach_type: breach_type.to_s }
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if Escalated.configuration.notification_channels.include?(:email)
|
|
156
|
+
Escalated::TicketMailer.sla_breach(ticket).deliver_later
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
NotificationService.dispatch(:sla_breached, ticket: ticket, breach_type: breach_type)
|
|
160
|
+
|
|
161
|
+
ActiveSupport::Notifications.instrument("escalated.sla.breached", {
|
|
162
|
+
ticket: ticket,
|
|
163
|
+
breach_type: breach_type
|
|
164
|
+
})
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def calculate_business_hours_due_date(hours)
|
|
168
|
+
bh = Escalated.configuration.business_hours
|
|
169
|
+
start_hour = bh[:start] || 9
|
|
170
|
+
end_hour = bh[:end] || 17
|
|
171
|
+
working_days = bh[:working_days] || [1, 2, 3, 4, 5]
|
|
172
|
+
tz = bh[:timezone] || "UTC"
|
|
173
|
+
|
|
174
|
+
current_time = Time.current.in_time_zone(tz)
|
|
175
|
+
remaining_hours = hours.to_f
|
|
176
|
+
|
|
177
|
+
while remaining_hours > 0
|
|
178
|
+
if working_days.include?(current_time.wday)
|
|
179
|
+
day_start = current_time.change(hour: start_hour, min: 0, sec: 0)
|
|
180
|
+
day_end = current_time.change(hour: end_hour, min: 0, sec: 0)
|
|
181
|
+
|
|
182
|
+
current_time = day_start if current_time < day_start
|
|
183
|
+
|
|
184
|
+
if current_time < day_end
|
|
185
|
+
available_hours = (day_end - current_time) / 3600.0
|
|
186
|
+
|
|
187
|
+
if remaining_hours <= available_hours
|
|
188
|
+
return current_time + remaining_hours.hours
|
|
189
|
+
else
|
|
190
|
+
remaining_hours -= available_hours
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
current_time = (current_time + 1.day).change(hour: start_hour, min: 0, sec: 0)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
current_time
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Services
|
|
3
|
+
class TicketService
|
|
4
|
+
class << self
|
|
5
|
+
def create(params)
|
|
6
|
+
ticket = driver.create_ticket(params)
|
|
7
|
+
|
|
8
|
+
if Escalated.configuration.notification_channels.include?(:email)
|
|
9
|
+
Escalated::TicketMailer.new_ticket(ticket).deliver_later
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Services::NotificationService.dispatch(:ticket_created, ticket: ticket)
|
|
13
|
+
|
|
14
|
+
ticket
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(ticket, params, actor:)
|
|
18
|
+
driver.update_ticket(ticket, params, actor: actor)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def transition_status(ticket, new_status, actor:, note: nil)
|
|
22
|
+
result = driver.transition_status(ticket, new_status, actor: actor, note: note)
|
|
23
|
+
|
|
24
|
+
if Escalated.configuration.notification_channels.include?(:email)
|
|
25
|
+
Escalated::TicketMailer.status_changed(result).deliver_later
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if new_status.to_s == "resolved"
|
|
29
|
+
Escalated::TicketMailer.ticket_resolved(result).deliver_later if Escalated.configuration.notification_channels.include?(:email)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Services::NotificationService.dispatch(:status_changed, ticket: result, status: new_status)
|
|
33
|
+
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def assign(ticket, agent, actor:)
|
|
38
|
+
result = driver.assign_ticket(ticket, agent, actor: actor)
|
|
39
|
+
|
|
40
|
+
if Escalated.configuration.notification_channels.include?(:email)
|
|
41
|
+
Escalated::TicketMailer.ticket_assigned(result).deliver_later
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Services::NotificationService.dispatch(:ticket_assigned, ticket: result, agent: agent)
|
|
45
|
+
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unassign(ticket, actor:)
|
|
50
|
+
driver.unassign_ticket(ticket, actor: actor)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reply(ticket, params)
|
|
54
|
+
reply = driver.add_reply(ticket, params)
|
|
55
|
+
|
|
56
|
+
if !params[:is_internal] && Escalated.configuration.notification_channels.include?(:email)
|
|
57
|
+
Escalated::TicketMailer.reply_received(ticket, reply).deliver_later
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Services::NotificationService.dispatch(:reply_added, ticket: ticket, reply: reply)
|
|
61
|
+
|
|
62
|
+
reply
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def find(id)
|
|
66
|
+
driver.get_ticket(id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def list(filters = {})
|
|
70
|
+
driver.list_tickets(filters)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_tags(ticket, tag_ids, actor:)
|
|
74
|
+
driver.add_tags(ticket, tag_ids, actor: actor)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remove_tags(ticket, tag_ids, actor:)
|
|
78
|
+
driver.remove_tags(ticket, tag_ids, actor: actor)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def change_department(ticket, department, actor:)
|
|
82
|
+
driver.change_department(ticket, department, actor: actor)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def change_priority(ticket, new_priority, actor:)
|
|
86
|
+
result = driver.change_priority(ticket, new_priority, actor: actor)
|
|
87
|
+
|
|
88
|
+
Services::NotificationService.dispatch(:priority_changed, ticket: result, priority: new_priority)
|
|
89
|
+
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close(ticket, actor:)
|
|
94
|
+
transition_status(ticket, :closed, actor: actor)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reopen(ticket, actor:)
|
|
98
|
+
transition_status(ticket, :reopened, actor: actor)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve(ticket, actor:)
|
|
102
|
+
transition_status(ticket, :resolved, actor: actor)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def driver
|
|
108
|
+
Escalated.driver
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/escalated.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "escalated/engine"
|
|
2
|
+
require "escalated/configuration"
|
|
3
|
+
require "escalated/manager"
|
|
4
|
+
|
|
5
|
+
module Escalated
|
|
6
|
+
class << self
|
|
7
|
+
attr_writer :configuration
|
|
8
|
+
|
|
9
|
+
def configuration
|
|
10
|
+
@configuration ||= Configuration.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def configure
|
|
14
|
+
yield(configuration)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def driver
|
|
18
|
+
Manager.driver
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def table_name(name)
|
|
22
|
+
"#{configuration.table_prefix}#{name}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/migration"
|
|
3
|
+
|
|
4
|
+
module Escalated
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Installs the Escalated support ticket system"
|
|
12
|
+
|
|
13
|
+
def self.next_migration_number(dirname)
|
|
14
|
+
Time.now.strftime("%Y%m%d%H%M%S")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def copy_initializer
|
|
18
|
+
template "initializer.rb", "config/initializers/escalated.rb"
|
|
19
|
+
say_status :create, "config/initializers/escalated.rb", :green
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_migrations
|
|
23
|
+
rake "escalated:install:migrations"
|
|
24
|
+
say_status :info, "Copied migrations. Run `rails db:migrate` to apply.", :yellow
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_user_concern
|
|
28
|
+
inject_into_file(
|
|
29
|
+
"app/models/user.rb",
|
|
30
|
+
after: "class User < ApplicationRecord\n"
|
|
31
|
+
) do
|
|
32
|
+
<<-RUBY
|
|
33
|
+
# Escalated support system role methods
|
|
34
|
+
# Customize these methods to match your authorization system
|
|
35
|
+
def escalated_agent?
|
|
36
|
+
# Return true if this user is a support agent
|
|
37
|
+
respond_to?(:role) && %w[agent admin].include?(role)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def escalated_admin?
|
|
41
|
+
# Return true if this user is a support admin
|
|
42
|
+
respond_to?(:role) && role == "admin"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.escalated_agents
|
|
46
|
+
# Return a scope of all support agents
|
|
47
|
+
where(role: %w[agent admin])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
RUBY
|
|
51
|
+
end
|
|
52
|
+
say_status :inject, "app/models/user.rb (Escalated role methods)", :green
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
say_status :skip, "Could not inject into User model: #{e.message}", :yellow
|
|
55
|
+
say " Add these methods to your User model manually:"
|
|
56
|
+
say " escalated_agent? - returns true for support agents"
|
|
57
|
+
say " escalated_admin? - returns true for support admins"
|
|
58
|
+
say " self.escalated_agents - scope returning all agents"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_post_install
|
|
62
|
+
say ""
|
|
63
|
+
say "Escalated installed successfully!", :green
|
|
64
|
+
say ""
|
|
65
|
+
say "Next steps:"
|
|
66
|
+
say " 1. Run `rails db:migrate`"
|
|
67
|
+
say " 2. Configure config/initializers/escalated.rb"
|
|
68
|
+
say " 3. Add escalated_agent? and escalated_admin? methods to your User model"
|
|
69
|
+
say " 4. Install Vue components: copy from vendor/escalated/resources/js/"
|
|
70
|
+
say " 5. Set up your Inertia page resolver to include Escalated pages"
|
|
71
|
+
say ""
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Escalated.configure do |config|
|
|
2
|
+
# ============================================================
|
|
3
|
+
# Hosting Mode
|
|
4
|
+
# ============================================================
|
|
5
|
+
# :self_hosted - All data in your local database (default)
|
|
6
|
+
# :synced - Local database + cloud sync
|
|
7
|
+
# :cloud - All data proxied to Escalated Cloud
|
|
8
|
+
config.mode = :self_hosted
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# User Configuration
|
|
12
|
+
# ============================================================
|
|
13
|
+
# Your application's user model class name
|
|
14
|
+
config.user_class = "User"
|
|
15
|
+
|
|
16
|
+
# ============================================================
|
|
17
|
+
# Database
|
|
18
|
+
# ============================================================
|
|
19
|
+
# Prefix for all Escalated database tables
|
|
20
|
+
config.table_prefix = "escalated_"
|
|
21
|
+
|
|
22
|
+
# ============================================================
|
|
23
|
+
# Routing
|
|
24
|
+
# ============================================================
|
|
25
|
+
# URL prefix for Escalated routes (e.g., /support/tickets)
|
|
26
|
+
config.route_prefix = "support"
|
|
27
|
+
|
|
28
|
+
# ============================================================
|
|
29
|
+
# Authentication & Authorization
|
|
30
|
+
# ============================================================
|
|
31
|
+
# Middleware applied to all Escalated routes
|
|
32
|
+
config.middleware = [:authenticate_user!]
|
|
33
|
+
|
|
34
|
+
# Additional middleware for admin routes (nil = same as middleware)
|
|
35
|
+
config.admin_middleware = nil
|
|
36
|
+
|
|
37
|
+
# ============================================================
|
|
38
|
+
# Ticket Settings
|
|
39
|
+
# ============================================================
|
|
40
|
+
# Allow customers to close their own tickets
|
|
41
|
+
config.allow_customer_close = true
|
|
42
|
+
|
|
43
|
+
# Automatically close resolved tickets after N days (nil to disable)
|
|
44
|
+
config.auto_close_resolved_after_days = 7
|
|
45
|
+
|
|
46
|
+
# Default priority for new tickets
|
|
47
|
+
config.default_priority = :medium
|
|
48
|
+
|
|
49
|
+
# ============================================================
|
|
50
|
+
# Attachments
|
|
51
|
+
# ============================================================
|
|
52
|
+
# Maximum number of attachments per ticket/reply
|
|
53
|
+
config.max_attachments = 5
|
|
54
|
+
|
|
55
|
+
# Maximum file size in KB (10 MB default)
|
|
56
|
+
config.max_attachment_size_kb = 10_240
|
|
57
|
+
|
|
58
|
+
# ActiveStorage service to use (:local, :amazon, :google, etc.)
|
|
59
|
+
config.storage_service = :local
|
|
60
|
+
|
|
61
|
+
# ============================================================
|
|
62
|
+
# SLA Configuration
|
|
63
|
+
# ============================================================
|
|
64
|
+
config.sla = {
|
|
65
|
+
enabled: true,
|
|
66
|
+
business_hours_only: true,
|
|
67
|
+
business_hours: {
|
|
68
|
+
start: 9,
|
|
69
|
+
end: 17,
|
|
70
|
+
timezone: "UTC",
|
|
71
|
+
working_days: [1, 2, 3, 4, 5] # Monday through Friday
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# ============================================================
|
|
76
|
+
# Notifications
|
|
77
|
+
# ============================================================
|
|
78
|
+
# Available channels: :email
|
|
79
|
+
config.notification_channels = [:email]
|
|
80
|
+
|
|
81
|
+
# Webhook URL for external integrations (nil to disable)
|
|
82
|
+
config.webhook_url = nil
|
|
83
|
+
|
|
84
|
+
# ============================================================
|
|
85
|
+
# Cloud Configuration (only for :synced and :cloud modes)
|
|
86
|
+
# ============================================================
|
|
87
|
+
# config.hosted_api_url = "https://cloud.escalated.dev/api/v1"
|
|
88
|
+
# config.hosted_api_key = ENV["ESCALATED_API_KEY"]
|
|
89
|
+
end
|