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,121 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class EscalationRulesController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
before_action :set_rule, only: [:show, :edit, :update, :destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
rules = Escalated::EscalationRule.ordered
|
|
9
|
+
|
|
10
|
+
render inertia: "Escalated/Admin/EscalationRules/Index", props: {
|
|
11
|
+
escalation_rules: rules.map { |r| rule_json(r) }
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def new
|
|
16
|
+
render inertia: "Escalated/Admin/EscalationRules/Form", props: {
|
|
17
|
+
escalation_rule: nil,
|
|
18
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
19
|
+
agents: agent_list,
|
|
20
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
21
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create
|
|
26
|
+
rule = Escalated::EscalationRule.new(rule_params)
|
|
27
|
+
|
|
28
|
+
if rule.save
|
|
29
|
+
redirect_to admin_escalation_rule_path(rule), notice: "Escalation rule created."
|
|
30
|
+
else
|
|
31
|
+
redirect_back fallback_location: new_admin_escalation_rule_path,
|
|
32
|
+
alert: rule.errors.full_messages.join(", ")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show
|
|
37
|
+
render inertia: "Escalated/Admin/EscalationRules/Show", props: {
|
|
38
|
+
escalation_rule: rule_json(@rule)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def edit
|
|
43
|
+
render inertia: "Escalated/Admin/EscalationRules/Form", props: {
|
|
44
|
+
escalation_rule: rule_json(@rule),
|
|
45
|
+
departments: Escalated::Department.active.ordered.map { |d| { id: d.id, name: d.name } },
|
|
46
|
+
agents: agent_list,
|
|
47
|
+
statuses: Escalated::Ticket.statuses.keys,
|
|
48
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update
|
|
53
|
+
if @rule.update(rule_params)
|
|
54
|
+
redirect_to admin_escalation_rule_path(@rule), notice: "Escalation rule updated."
|
|
55
|
+
else
|
|
56
|
+
redirect_back fallback_location: edit_admin_escalation_rule_path(@rule),
|
|
57
|
+
alert: @rule.errors.full_messages.join(", ")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def destroy
|
|
62
|
+
@rule.destroy!
|
|
63
|
+
redirect_to admin_escalation_rules_path, notice: "Escalation rule deleted."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def set_rule
|
|
69
|
+
@rule = Escalated::EscalationRule.find(params[:id])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def rule_params
|
|
73
|
+
params.require(:escalation_rule).permit(
|
|
74
|
+
:name, :description, :is_active, :priority,
|
|
75
|
+
conditions: {},
|
|
76
|
+
actions: {}
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rule_json(rule)
|
|
81
|
+
{
|
|
82
|
+
id: rule.id,
|
|
83
|
+
name: rule.name,
|
|
84
|
+
description: rule.description,
|
|
85
|
+
is_active: rule.is_active,
|
|
86
|
+
priority: rule.priority,
|
|
87
|
+
conditions: rule.conditions,
|
|
88
|
+
actions: rule.actions,
|
|
89
|
+
created_at: rule.created_at&.iso8601,
|
|
90
|
+
updated_at: rule.updated_at&.iso8601
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def agent_list
|
|
95
|
+
if Escalated.configuration.user_model.respond_to?(:escalated_agents)
|
|
96
|
+
Escalated.configuration.user_model.escalated_agents.map { |a|
|
|
97
|
+
{ id: a.id, name: a.respond_to?(:name) ? a.name : a.email, email: a.email }
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def admin_escalation_rule_path(rule)
|
|
105
|
+
escalated.admin_escalation_rule_path(rule)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def new_admin_escalation_rule_path
|
|
109
|
+
escalated.new_admin_escalation_rule_path
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def edit_admin_escalation_rule_path(rule)
|
|
113
|
+
escalated.edit_admin_escalation_rule_path(rule)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def admin_escalation_rules_path
|
|
117
|
+
escalated.admin_escalation_rules_path
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class MacrosController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
before_action :set_macro, only: [:update, :destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
macros = Escalated::Macro.ordered
|
|
9
|
+
|
|
10
|
+
render inertia: "Escalated/Admin/Macros/Index", props: {
|
|
11
|
+
macros: macros.map { |m| macro_json(m) }
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
macro = Escalated::Macro.new(macro_params)
|
|
17
|
+
macro.created_by = escalated_current_user.id
|
|
18
|
+
|
|
19
|
+
if macro.save
|
|
20
|
+
redirect_to admin_macros_path, notice: "Macro created."
|
|
21
|
+
else
|
|
22
|
+
redirect_back fallback_location: admin_macros_path,
|
|
23
|
+
alert: macro.errors.full_messages.join(", ")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update
|
|
28
|
+
if @macro.update(macro_params)
|
|
29
|
+
redirect_to admin_macros_path, notice: "Macro updated."
|
|
30
|
+
else
|
|
31
|
+
redirect_back fallback_location: admin_macros_path,
|
|
32
|
+
alert: @macro.errors.full_messages.join(", ")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def destroy
|
|
37
|
+
@macro.destroy!
|
|
38
|
+
redirect_to admin_macros_path, notice: "Macro deleted."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def set_macro
|
|
44
|
+
@macro = Escalated::Macro.find(params[:id])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def macro_params
|
|
48
|
+
params.require(:macro).permit(:name, :description, :is_shared, :order, actions: [:type, :value])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def macro_json(macro)
|
|
52
|
+
{
|
|
53
|
+
id: macro.id,
|
|
54
|
+
name: macro.name,
|
|
55
|
+
description: macro.description,
|
|
56
|
+
actions: macro.actions,
|
|
57
|
+
is_shared: macro.is_shared,
|
|
58
|
+
order: macro.order,
|
|
59
|
+
creator: macro.creator ? {
|
|
60
|
+
id: macro.creator.id,
|
|
61
|
+
name: macro.creator.respond_to?(:name) ? macro.creator.name : macro.creator.email
|
|
62
|
+
} : nil,
|
|
63
|
+
created_at: macro.created_at&.iso8601,
|
|
64
|
+
updated_at: macro.updated_at&.iso8601
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def admin_macros_path
|
|
69
|
+
escalated.admin_macros_path
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class ReportsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
period_start = parse_date(params[:from]) || 30.days.ago.beginning_of_day
|
|
8
|
+
period_end = parse_date(params[:to]) || Time.current.end_of_day
|
|
9
|
+
|
|
10
|
+
tickets_in_period = Escalated::Ticket.where(created_at: period_start..period_end)
|
|
11
|
+
|
|
12
|
+
stats = {
|
|
13
|
+
overview: {
|
|
14
|
+
total_created: tickets_in_period.count,
|
|
15
|
+
total_resolved: tickets_in_period.where.not(resolved_at: nil).count,
|
|
16
|
+
total_closed: tickets_in_period.where(status: :closed).count,
|
|
17
|
+
currently_open: Escalated::Ticket.by_open.count,
|
|
18
|
+
currently_unassigned: Escalated::Ticket.by_open.unassigned.count
|
|
19
|
+
},
|
|
20
|
+
by_status: Escalated::Ticket.statuses.keys.each_with_object({}) { |status, hash|
|
|
21
|
+
hash[status] = Escalated::Ticket.where(status: status).count
|
|
22
|
+
},
|
|
23
|
+
by_priority: Escalated::Ticket.priorities.keys.each_with_object({}) { |priority, hash|
|
|
24
|
+
hash[priority] = tickets_in_period.where(priority: priority).count
|
|
25
|
+
},
|
|
26
|
+
by_department: Escalated::Department.ordered.map { |dept|
|
|
27
|
+
{
|
|
28
|
+
id: dept.id,
|
|
29
|
+
name: dept.name,
|
|
30
|
+
total: tickets_in_period.where(department_id: dept.id).count,
|
|
31
|
+
open: Escalated::Ticket.by_open.where(department_id: dept.id).count
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
sla: Services::SlaService.stats,
|
|
35
|
+
performance: {
|
|
36
|
+
avg_first_response_hours: calculate_avg(:first_response, period_start, period_end),
|
|
37
|
+
avg_resolution_hours: calculate_avg(:resolution, period_start, period_end),
|
|
38
|
+
tickets_per_day: tickets_in_period.count.to_f / [(period_end - period_start) / 1.day, 1].max
|
|
39
|
+
},
|
|
40
|
+
trends: calculate_daily_trends(period_start, period_end),
|
|
41
|
+
top_agents: calculate_top_agents(period_start, period_end),
|
|
42
|
+
csat: calculate_csat_stats(period_start, period_end)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
render inertia: "Escalated/Admin/Reports/Index", props: {
|
|
46
|
+
stats: stats,
|
|
47
|
+
filters: {
|
|
48
|
+
from: period_start.iso8601,
|
|
49
|
+
to: period_end.iso8601
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_date(value)
|
|
57
|
+
return nil unless value.present?
|
|
58
|
+
|
|
59
|
+
Time.zone.parse(value)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def calculate_avg(type, from, to)
|
|
65
|
+
scope = Escalated::Ticket.where(created_at: from..to)
|
|
66
|
+
|
|
67
|
+
case type
|
|
68
|
+
when :first_response
|
|
69
|
+
tickets = scope.where.not(first_response_at: nil)
|
|
70
|
+
return 0 if tickets.empty?
|
|
71
|
+
|
|
72
|
+
total = tickets.sum { |t| (t.first_response_at - t.created_at).to_f }
|
|
73
|
+
(total / tickets.count / 3600.0).round(1)
|
|
74
|
+
when :resolution
|
|
75
|
+
tickets = scope.where.not(resolved_at: nil)
|
|
76
|
+
return 0 if tickets.empty?
|
|
77
|
+
|
|
78
|
+
total = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
|
|
79
|
+
(total / tickets.count / 3600.0).round(1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def calculate_daily_trends(from, to)
|
|
84
|
+
days = ((to - from) / 1.day).ceil
|
|
85
|
+
days = [days, 90].min # Cap at 90 days
|
|
86
|
+
|
|
87
|
+
(0...days).map do |i|
|
|
88
|
+
day_start = from + i.days
|
|
89
|
+
day_end = day_start + 1.day
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
date: day_start.strftime("%Y-%m-%d"),
|
|
93
|
+
created: Escalated::Ticket.where(created_at: day_start..day_end).count,
|
|
94
|
+
resolved: Escalated::Ticket.where(resolved_at: day_start..day_end).count,
|
|
95
|
+
closed: Escalated::Ticket.where(closed_at: day_start..day_end).count
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def calculate_top_agents(from, to)
|
|
101
|
+
resolved_counts = Escalated::Ticket
|
|
102
|
+
.where(resolved_at: from..to)
|
|
103
|
+
.where.not(assigned_to: nil)
|
|
104
|
+
.group(:assigned_to)
|
|
105
|
+
.count
|
|
106
|
+
.sort_by { |_, count| -count }
|
|
107
|
+
.first(10)
|
|
108
|
+
|
|
109
|
+
resolved_counts.map do |agent_id, count|
|
|
110
|
+
agent = Escalated.configuration.user_model.find_by(id: agent_id)
|
|
111
|
+
next unless agent
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
id: agent.id,
|
|
115
|
+
name: agent.respond_to?(:name) ? agent.name : agent.email,
|
|
116
|
+
resolved_count: count,
|
|
117
|
+
avg_resolution_hours: calculate_agent_avg_resolution(agent_id, from, to)
|
|
118
|
+
}
|
|
119
|
+
end.compact
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def calculate_csat_stats(from, to)
|
|
123
|
+
ratings = Escalated::SatisfactionRating.where(created_at: from..to)
|
|
124
|
+
|
|
125
|
+
total = ratings.count
|
|
126
|
+
return { average: 0, total: 0, breakdown: {} } if total.zero?
|
|
127
|
+
|
|
128
|
+
average = (ratings.average(:rating).to_f).round(2)
|
|
129
|
+
breakdown = (1..5).each_with_object({}) do |star, hash|
|
|
130
|
+
hash[star] = ratings.where(rating: star).count
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
average: average,
|
|
135
|
+
total: total,
|
|
136
|
+
breakdown: breakdown
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def calculate_agent_avg_resolution(agent_id, from, to)
|
|
141
|
+
tickets = Escalated::Ticket
|
|
142
|
+
.where(assigned_to: agent_id, resolved_at: from..to)
|
|
143
|
+
.where.not(resolved_at: nil)
|
|
144
|
+
|
|
145
|
+
return 0 if tickets.empty?
|
|
146
|
+
|
|
147
|
+
total = tickets.sum { |t| (t.resolved_at - t.created_at).to_f }
|
|
148
|
+
(total / tickets.count / 3600.0).round(1)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class SettingsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
|
|
6
|
+
SENSITIVE_KEYS = %w[mailgun_signing_key postmark_inbound_token imap_password].freeze
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
settings = Escalated::EscalatedSetting.all_as_hash
|
|
10
|
+
SENSITIVE_KEYS.each do |key|
|
|
11
|
+
settings[key] = mask_secret(settings[key]) if settings.key?(key)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
render inertia: "Escalated/Admin/Settings", props: {
|
|
15
|
+
settings: settings
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def update
|
|
20
|
+
# Boolean settings
|
|
21
|
+
%w[guest_tickets_enabled allow_customer_close inbound_email_enabled].each do |key|
|
|
22
|
+
value = params[key].in?(%w[1 true on]) ? "1" : "0"
|
|
23
|
+
Escalated::EscalatedSetting.set(key, value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Integer settings
|
|
27
|
+
%w[auto_close_resolved_after_days max_attachments_per_reply max_attachment_size_kb].each do |key|
|
|
28
|
+
raw = params[key]
|
|
29
|
+
next unless raw.present?
|
|
30
|
+
|
|
31
|
+
int_val = raw.to_i
|
|
32
|
+
Escalated::EscalatedSetting.set(key, [0, int_val].max.to_s) if int_val >= 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# String settings
|
|
36
|
+
prefix = params[:ticket_reference_prefix].to_s.strip
|
|
37
|
+
if prefix.present? && prefix.match?(/\A[a-zA-Z0-9]+\z/) && prefix.length <= 10
|
|
38
|
+
Escalated::EscalatedSetting.set("ticket_reference_prefix", prefix)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Inbound email settings
|
|
42
|
+
update_inbound_email_settings
|
|
43
|
+
|
|
44
|
+
redirect_to escalated.admin_settings_path, notice: "Settings updated successfully."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def update_inbound_email_settings
|
|
50
|
+
# Adapter selection (mailgun, postmark, ses, imap)
|
|
51
|
+
adapter = params[:inbound_email_adapter].to_s.strip.downcase
|
|
52
|
+
if adapter.present? && %w[mailgun postmark ses imap].include?(adapter)
|
|
53
|
+
Escalated::EscalatedSetting.set("inbound_email_adapter", adapter)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Inbound email address
|
|
57
|
+
address = params[:inbound_email_address].to_s.strip
|
|
58
|
+
if address.present? && address.match?(/\A[^@\s]+@[^@\s]+\z/)
|
|
59
|
+
Escalated::EscalatedSetting.set("inbound_email_address", address)
|
|
60
|
+
elsif params.key?(:inbound_email_address) && address.blank?
|
|
61
|
+
Escalated::EscalatedSetting.set("inbound_email_address", "")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Adapter-specific string settings (only save if present, skip masked values)
|
|
65
|
+
%w[
|
|
66
|
+
mailgun_signing_key postmark_inbound_token
|
|
67
|
+
ses_region ses_topic_arn
|
|
68
|
+
imap_host imap_username imap_password imap_mailbox
|
|
69
|
+
].each do |key|
|
|
70
|
+
next unless params.key?(key)
|
|
71
|
+
|
|
72
|
+
raw = params[key].to_s.strip
|
|
73
|
+
next if SENSITIVE_KEYS.include?(key) && masked_value?(raw)
|
|
74
|
+
|
|
75
|
+
Escalated::EscalatedSetting.set(key, raw)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# IMAP port (integer)
|
|
79
|
+
if params[:imap_port].present?
|
|
80
|
+
port = params[:imap_port].to_i
|
|
81
|
+
Escalated::EscalatedSetting.set("imap_port", port.to_s) if port > 0 && port <= 65535
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# IMAP encryption (ssl, tls, starttls, none)
|
|
85
|
+
encryption = params[:imap_encryption].to_s.strip.downcase
|
|
86
|
+
if encryption.present? && %w[ssl tls starttls none].include?(encryption)
|
|
87
|
+
Escalated::EscalatedSetting.set("imap_encryption", encryption)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def mask_secret(value)
|
|
92
|
+
return '' if value.blank?
|
|
93
|
+
|
|
94
|
+
len = value.length
|
|
95
|
+
return '*' * len if len <= 6
|
|
96
|
+
|
|
97
|
+
value[0, 3] + '*' * [len - 3, 12].min
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def masked_value?(value)
|
|
101
|
+
return false if value.blank?
|
|
102
|
+
|
|
103
|
+
value.match?(/\A.{0,3}\*{3,}\z/)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def admin_settings_path
|
|
107
|
+
"/#{Escalated.configuration.route_prefix}/admin/settings"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class SlaPoliciesController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
before_action :set_sla_policy, only: [:show, :edit, :update, :destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
policies = Escalated::SlaPolicy.ordered
|
|
9
|
+
|
|
10
|
+
render inertia: "Escalated/Admin/SlaPolicies/Index", props: {
|
|
11
|
+
sla_policies: policies.map { |p| sla_policy_json(p) }
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def new
|
|
16
|
+
render inertia: "Escalated/Admin/SlaPolicies/Form", props: {
|
|
17
|
+
sla_policy: nil,
|
|
18
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create
|
|
23
|
+
policy = Escalated::SlaPolicy.new(sla_policy_params)
|
|
24
|
+
|
|
25
|
+
if policy.save
|
|
26
|
+
redirect_to admin_sla_policy_path(policy), notice: "SLA Policy created."
|
|
27
|
+
else
|
|
28
|
+
redirect_back fallback_location: new_admin_sla_policy_path,
|
|
29
|
+
alert: policy.errors.full_messages.join(", ")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def show
|
|
34
|
+
render inertia: "Escalated/Admin/SlaPolicies/Show", props: {
|
|
35
|
+
sla_policy: sla_policy_json(@sla_policy),
|
|
36
|
+
targets: @sla_policy.priority_targets,
|
|
37
|
+
department_count: @sla_policy.departments.count,
|
|
38
|
+
ticket_count: @sla_policy.tickets.count
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def edit
|
|
43
|
+
render inertia: "Escalated/Admin/SlaPolicies/Form", props: {
|
|
44
|
+
sla_policy: sla_policy_json(@sla_policy),
|
|
45
|
+
priorities: Escalated::Ticket.priorities.keys
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def update
|
|
50
|
+
if @sla_policy.update(sla_policy_params)
|
|
51
|
+
redirect_to admin_sla_policy_path(@sla_policy), notice: "SLA Policy updated."
|
|
52
|
+
else
|
|
53
|
+
redirect_back fallback_location: edit_admin_sla_policy_path(@sla_policy),
|
|
54
|
+
alert: @sla_policy.errors.full_messages.join(", ")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def destroy
|
|
59
|
+
@sla_policy.destroy!
|
|
60
|
+
redirect_to admin_sla_policies_path, notice: "SLA Policy deleted."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def set_sla_policy
|
|
66
|
+
@sla_policy = Escalated::SlaPolicy.find(params[:id])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sla_policy_params
|
|
70
|
+
params.require(:sla_policy).permit(
|
|
71
|
+
:name, :description, :is_active, :is_default,
|
|
72
|
+
first_response_hours: {},
|
|
73
|
+
resolution_hours: {}
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def sla_policy_json(policy)
|
|
78
|
+
{
|
|
79
|
+
id: policy.id,
|
|
80
|
+
name: policy.name,
|
|
81
|
+
description: policy.description,
|
|
82
|
+
is_active: policy.is_active,
|
|
83
|
+
is_default: policy.is_default,
|
|
84
|
+
first_response_hours: policy.first_response_hours,
|
|
85
|
+
resolution_hours: policy.resolution_hours,
|
|
86
|
+
targets: policy.priority_targets,
|
|
87
|
+
created_at: policy.created_at&.iso8601,
|
|
88
|
+
updated_at: policy.updated_at&.iso8601
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def admin_sla_policy_path(policy)
|
|
93
|
+
escalated.admin_sla_policy_path(policy)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def new_admin_sla_policy_path
|
|
97
|
+
escalated.new_admin_sla_policy_path
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def edit_admin_sla_policy_path(policy)
|
|
101
|
+
escalated.edit_admin_sla_policy_path(policy)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def admin_sla_policies_path
|
|
105
|
+
escalated.admin_sla_policies_path
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
module Admin
|
|
3
|
+
class TagsController < Escalated::ApplicationController
|
|
4
|
+
before_action :require_admin!
|
|
5
|
+
before_action :set_tag, only: [:update, :destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
tags = Escalated::Tag.ordered
|
|
9
|
+
|
|
10
|
+
render inertia: "Escalated/Admin/Tags/Index", props: {
|
|
11
|
+
tags: tags.map { |t| tag_json(t) }
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
tag = Escalated::Tag.new(tag_params)
|
|
17
|
+
|
|
18
|
+
if tag.save
|
|
19
|
+
redirect_to admin_tags_path, notice: "Tag created."
|
|
20
|
+
else
|
|
21
|
+
redirect_back fallback_location: admin_tags_path,
|
|
22
|
+
alert: tag.errors.full_messages.join(", ")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update
|
|
27
|
+
if @tag.update(tag_params)
|
|
28
|
+
redirect_to admin_tags_path, notice: "Tag updated."
|
|
29
|
+
else
|
|
30
|
+
redirect_back fallback_location: admin_tags_path,
|
|
31
|
+
alert: @tag.errors.full_messages.join(", ")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy
|
|
36
|
+
@tag.destroy!
|
|
37
|
+
redirect_to admin_tags_path, notice: "Tag deleted."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def set_tag
|
|
43
|
+
@tag = Escalated::Tag.find(params[:id])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tag_params
|
|
47
|
+
params.require(:tag).permit(:name, :color, :description)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tag_json(tag)
|
|
51
|
+
{
|
|
52
|
+
id: tag.id,
|
|
53
|
+
name: tag.name,
|
|
54
|
+
slug: tag.slug,
|
|
55
|
+
color: tag.color,
|
|
56
|
+
description: tag.description,
|
|
57
|
+
ticket_count: tag.ticket_count,
|
|
58
|
+
created_at: tag.created_at&.iso8601
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def admin_tags_path
|
|
63
|
+
escalated.admin_tags_path
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|