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,30 @@
|
|
|
1
|
+
class CreateEscalatedInboundEmails < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table Escalated.table_name("inbound_emails") do |t|
|
|
4
|
+
t.string :message_id
|
|
5
|
+
t.string :from_email, null: false
|
|
6
|
+
t.string :from_name
|
|
7
|
+
t.string :to_email, null: false
|
|
8
|
+
t.string :subject, null: false
|
|
9
|
+
t.text :body_text
|
|
10
|
+
t.text :body_html
|
|
11
|
+
t.text :raw_headers
|
|
12
|
+
|
|
13
|
+
t.references :ticket, foreign_key: { to_table: Escalated.table_name("tickets") }, null: true
|
|
14
|
+
t.references :reply, foreign_key: { to_table: Escalated.table_name("replies") }, null: true
|
|
15
|
+
|
|
16
|
+
t.string :status, default: "pending", null: false
|
|
17
|
+
t.string :adapter, null: false
|
|
18
|
+
t.text :error_message
|
|
19
|
+
|
|
20
|
+
t.datetime :processed_at
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
add_index Escalated.table_name("inbound_emails"), :message_id, unique: true
|
|
26
|
+
add_index Escalated.table_name("inbound_emails"), :from_email
|
|
27
|
+
add_index Escalated.table_name("inbound_emails"), :status
|
|
28
|
+
add_index Escalated.table_name("inbound_emails"), :processed_at
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateEscalatedMacros < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table Escalated.table_name("macros") do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :description
|
|
6
|
+
t.json :actions, null: false
|
|
7
|
+
t.bigint :created_by
|
|
8
|
+
t.boolean :is_shared, default: true, null: false
|
|
9
|
+
t.integer :order, default: 0, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index Escalated.table_name("macros"), :created_by
|
|
15
|
+
add_index Escalated.table_name("macros"), :is_shared
|
|
16
|
+
add_index Escalated.table_name("macros"), :order
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class CreateEscalatedTicketFollowers < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table Escalated.table_name("ticket_followers"), id: false do |t|
|
|
4
|
+
t.bigint :ticket_id, null: false
|
|
5
|
+
t.bigint :user_id, null: false
|
|
6
|
+
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
add_index Escalated.table_name("ticket_followers"),
|
|
11
|
+
[:ticket_id, :user_id],
|
|
12
|
+
unique: true,
|
|
13
|
+
name: "idx_escalated_ticket_followers_unique"
|
|
14
|
+
add_foreign_key Escalated.table_name("ticket_followers"),
|
|
15
|
+
Escalated.table_name("tickets"),
|
|
16
|
+
column: :ticket_id
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreateEscalatedSatisfactionRatings < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table Escalated.table_name("satisfaction_ratings") do |t|
|
|
4
|
+
t.references :ticket, null: false, foreign_key: { to_table: Escalated.table_name("tickets") }
|
|
5
|
+
t.integer :rating, null: false
|
|
6
|
+
t.text :comment
|
|
7
|
+
|
|
8
|
+
# Polymorphic rated_by (nullable for guest ratings)
|
|
9
|
+
t.string :rated_by_type
|
|
10
|
+
t.bigint :rated_by_id
|
|
11
|
+
|
|
12
|
+
t.datetime :created_at
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index Escalated.table_name("satisfaction_ratings"), [:rated_by_type, :rated_by_id]
|
|
16
|
+
add_index Escalated.table_name("satisfaction_ratings"),
|
|
17
|
+
:ticket_id,
|
|
18
|
+
unique: true,
|
|
19
|
+
name: "idx_escalated_satisfaction_ratings_ticket"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Escalated
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :mode,
|
|
4
|
+
:user_class,
|
|
5
|
+
:table_prefix,
|
|
6
|
+
:route_prefix,
|
|
7
|
+
:middleware,
|
|
8
|
+
:admin_middleware,
|
|
9
|
+
:hosted_api_url,
|
|
10
|
+
:hosted_api_key,
|
|
11
|
+
:allow_customer_close,
|
|
12
|
+
:auto_close_resolved_after_days,
|
|
13
|
+
:max_attachments,
|
|
14
|
+
:max_attachment_size_kb,
|
|
15
|
+
:default_priority,
|
|
16
|
+
:sla,
|
|
17
|
+
:notification_channels,
|
|
18
|
+
:webhook_url,
|
|
19
|
+
:storage_service,
|
|
20
|
+
# Inbound email settings
|
|
21
|
+
:inbound_email_enabled,
|
|
22
|
+
:inbound_email_adapter,
|
|
23
|
+
:inbound_email_address,
|
|
24
|
+
# Mailgun
|
|
25
|
+
:mailgun_signing_key,
|
|
26
|
+
# Postmark
|
|
27
|
+
:postmark_inbound_token,
|
|
28
|
+
# AWS SES
|
|
29
|
+
:ses_region,
|
|
30
|
+
:ses_topic_arn,
|
|
31
|
+
# IMAP
|
|
32
|
+
:imap_host,
|
|
33
|
+
:imap_port,
|
|
34
|
+
:imap_encryption,
|
|
35
|
+
:imap_username,
|
|
36
|
+
:imap_password,
|
|
37
|
+
:imap_mailbox
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@mode = :self_hosted
|
|
41
|
+
@user_class = "User"
|
|
42
|
+
@table_prefix = "escalated_"
|
|
43
|
+
@route_prefix = "support"
|
|
44
|
+
@middleware = [:authenticate_user!]
|
|
45
|
+
@admin_middleware = nil
|
|
46
|
+
@hosted_api_url = nil
|
|
47
|
+
@hosted_api_key = nil
|
|
48
|
+
@allow_customer_close = true
|
|
49
|
+
@auto_close_resolved_after_days = 7
|
|
50
|
+
@max_attachments = 5
|
|
51
|
+
@max_attachment_size_kb = 10_240
|
|
52
|
+
@default_priority = :medium
|
|
53
|
+
@sla = {
|
|
54
|
+
enabled: true,
|
|
55
|
+
business_hours_only: true,
|
|
56
|
+
business_hours: {
|
|
57
|
+
start: 9,
|
|
58
|
+
end: 17,
|
|
59
|
+
timezone: "UTC",
|
|
60
|
+
working_days: [1, 2, 3, 4, 5]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
@notification_channels = [:email]
|
|
64
|
+
@webhook_url = nil
|
|
65
|
+
@storage_service = :local
|
|
66
|
+
|
|
67
|
+
# Inbound email defaults
|
|
68
|
+
@inbound_email_enabled = false
|
|
69
|
+
@inbound_email_adapter = nil # :mailgun, :postmark, :ses, :imap
|
|
70
|
+
@inbound_email_address = nil # e.g., "support@yourdomain.com"
|
|
71
|
+
@mailgun_signing_key = nil
|
|
72
|
+
@postmark_inbound_token = nil
|
|
73
|
+
@ses_region = nil
|
|
74
|
+
@ses_topic_arn = nil
|
|
75
|
+
@imap_host = nil
|
|
76
|
+
@imap_port = 993
|
|
77
|
+
@imap_encryption = :ssl
|
|
78
|
+
@imap_username = nil
|
|
79
|
+
@imap_password = nil
|
|
80
|
+
@imap_mailbox = "INBOX"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self_hosted?
|
|
84
|
+
mode == :self_hosted
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def synced?
|
|
88
|
+
mode == :synced
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cloud?
|
|
92
|
+
mode == :cloud
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sla_enabled?
|
|
96
|
+
sla[:enabled] == true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def business_hours_only?
|
|
100
|
+
sla[:business_hours_only] == true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def business_hours
|
|
104
|
+
sla[:business_hours] || {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def user_model
|
|
108
|
+
user_class.constantize
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "escalated/drivers/hosted_api_client"
|
|
2
|
+
|
|
3
|
+
module Escalated
|
|
4
|
+
module Drivers
|
|
5
|
+
class CloudDriver
|
|
6
|
+
def create_ticket(params)
|
|
7
|
+
response = client.post("/tickets", {
|
|
8
|
+
subject: params[:subject],
|
|
9
|
+
description: params[:description],
|
|
10
|
+
priority: params[:priority] || Escalated.configuration.default_priority,
|
|
11
|
+
requester_email: params[:requester]&.email,
|
|
12
|
+
department_id: params[:department_id],
|
|
13
|
+
tag_ids: params[:tag_ids],
|
|
14
|
+
metadata: params[:metadata]
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
build_ticket_from_response(response)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update_ticket(ticket, params, actor:)
|
|
21
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
22
|
+
|
|
23
|
+
response = client.patch("/tickets/#{reference}", {
|
|
24
|
+
subject: params[:subject],
|
|
25
|
+
description: params[:description],
|
|
26
|
+
metadata: params[:metadata]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
build_ticket_from_response(response)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def transition_status(ticket, new_status, actor:, note: nil)
|
|
33
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
34
|
+
|
|
35
|
+
response = client.post("/tickets/#{reference}/status", {
|
|
36
|
+
status: new_status,
|
|
37
|
+
note: note
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
build_ticket_from_response(response)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def assign_ticket(ticket, agent, actor:)
|
|
44
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
45
|
+
|
|
46
|
+
response = client.post("/tickets/#{reference}/assign", {
|
|
47
|
+
agent_email: agent.email
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
build_ticket_from_response(response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unassign_ticket(ticket, actor:)
|
|
54
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
55
|
+
|
|
56
|
+
response = client.post("/tickets/#{reference}/unassign")
|
|
57
|
+
build_ticket_from_response(response)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_reply(ticket, params)
|
|
61
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
62
|
+
|
|
63
|
+
response = client.post("/tickets/#{reference}/replies", {
|
|
64
|
+
body: params[:body],
|
|
65
|
+
author_email: params[:author]&.email,
|
|
66
|
+
is_internal: params[:is_internal] || false
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
OpenStruct.new(response)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_ticket(id)
|
|
73
|
+
response = client.get("/tickets/#{id}")
|
|
74
|
+
build_ticket_from_response(response)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def list_tickets(filters = {})
|
|
78
|
+
response = client.get("/tickets", filters)
|
|
79
|
+
response.map { |data| build_ticket_from_response(data) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_tags(ticket, tag_ids, actor:)
|
|
83
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
84
|
+
|
|
85
|
+
response = client.post("/tickets/#{reference}/tags", {
|
|
86
|
+
tag_ids: tag_ids
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
build_ticket_from_response(response)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def remove_tags(ticket, tag_ids, actor:)
|
|
93
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
94
|
+
|
|
95
|
+
response = client.delete("/tickets/#{reference}/tags", {
|
|
96
|
+
tag_ids: tag_ids
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
build_ticket_from_response(response)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def change_department(ticket, department, actor:)
|
|
103
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
104
|
+
|
|
105
|
+
response = client.post("/tickets/#{reference}/department", {
|
|
106
|
+
department_id: department.id
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
build_ticket_from_response(response)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def change_priority(ticket, new_priority, actor:)
|
|
113
|
+
reference = ticket.is_a?(String) ? ticket : ticket.reference
|
|
114
|
+
|
|
115
|
+
response = client.post("/tickets/#{reference}/priority", {
|
|
116
|
+
priority: new_priority
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
build_ticket_from_response(response)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def client
|
|
125
|
+
@client ||= HostedApiClient.new
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_ticket_from_response(data)
|
|
129
|
+
data = data.deep_symbolize_keys if data.respond_to?(:deep_symbolize_keys)
|
|
130
|
+
OpenStruct.new(data)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Escalated
|
|
6
|
+
module Drivers
|
|
7
|
+
class HostedApiClient
|
|
8
|
+
class ApiError < StandardError
|
|
9
|
+
attr_reader :status, :body
|
|
10
|
+
|
|
11
|
+
def initialize(message, status: nil, body: nil)
|
|
12
|
+
@status = status
|
|
13
|
+
@body = body
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ConnectionError < ApiError; end
|
|
19
|
+
class AuthenticationError < ApiError; end
|
|
20
|
+
class RateLimitError < ApiError; end
|
|
21
|
+
class ServerError < ApiError; end
|
|
22
|
+
|
|
23
|
+
TIMEOUT = 30
|
|
24
|
+
MAX_RETRIES = 3
|
|
25
|
+
RETRY_DELAY = 1
|
|
26
|
+
|
|
27
|
+
def initialize(api_url: nil, api_key: nil)
|
|
28
|
+
@api_url = api_url || Escalated.configuration.hosted_api_url
|
|
29
|
+
@api_key = api_key || Escalated.configuration.hosted_api_key
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "Escalated hosted_api_url is required for cloud/synced mode" if @api_url.blank?
|
|
32
|
+
raise ArgumentError, "Escalated hosted_api_key is required for cloud/synced mode" if @api_key.blank?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Class method for fire-and-forget sync operations
|
|
36
|
+
def self.emit(action, payload)
|
|
37
|
+
new.emit(action, payload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def emit(action, payload)
|
|
41
|
+
post("/sync/#{action}", payload)
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Rails.logger.error("[Escalated::HostedApiClient] Emit failed: #{e.message}")
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get(path, params = {})
|
|
48
|
+
uri = build_uri(path, params)
|
|
49
|
+
request = Net::HTTP::Get.new(uri)
|
|
50
|
+
execute_request(uri, request)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post(path, body = {})
|
|
54
|
+
uri = build_uri(path)
|
|
55
|
+
request = Net::HTTP::Post.new(uri)
|
|
56
|
+
request.body = body.to_json
|
|
57
|
+
execute_request(uri, request)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def patch(path, body = {})
|
|
61
|
+
uri = build_uri(path)
|
|
62
|
+
request = Net::HTTP::Patch.new(uri)
|
|
63
|
+
request.body = body.to_json
|
|
64
|
+
execute_request(uri, request)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def put(path, body = {})
|
|
68
|
+
uri = build_uri(path)
|
|
69
|
+
request = Net::HTTP::Put.new(uri)
|
|
70
|
+
request.body = body.to_json
|
|
71
|
+
execute_request(uri, request)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def delete(path, body = {})
|
|
75
|
+
uri = build_uri(path)
|
|
76
|
+
request = Net::HTTP::Delete.new(uri)
|
|
77
|
+
request.body = body.to_json if body.present?
|
|
78
|
+
execute_request(uri, request)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def build_uri(path, params = {})
|
|
84
|
+
url = "#{@api_url.chomp('/')}#{path}"
|
|
85
|
+
uri = URI.parse(url)
|
|
86
|
+
|
|
87
|
+
if params.present?
|
|
88
|
+
query = params.compact.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
89
|
+
uri.query = query
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
uri
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def execute_request(uri, request, retries: 0)
|
|
96
|
+
apply_headers(request)
|
|
97
|
+
|
|
98
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
|
+
http.use_ssl = uri.scheme == "https"
|
|
100
|
+
http.open_timeout = TIMEOUT
|
|
101
|
+
http.read_timeout = TIMEOUT
|
|
102
|
+
|
|
103
|
+
response = http.request(request)
|
|
104
|
+
handle_response(response)
|
|
105
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
|
|
106
|
+
if retries < MAX_RETRIES
|
|
107
|
+
sleep(RETRY_DELAY * (retries + 1))
|
|
108
|
+
execute_request(uri, request, retries: retries + 1)
|
|
109
|
+
else
|
|
110
|
+
raise ConnectionError.new(
|
|
111
|
+
"Failed to connect to Escalated API after #{MAX_RETRIES} retries: #{e.message}"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply_headers(request)
|
|
117
|
+
request["Content-Type"] = "application/json"
|
|
118
|
+
request["Accept"] = "application/json"
|
|
119
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
120
|
+
request["User-Agent"] = "Escalated-Rails/#{Escalated::VERSION rescue '0.1.0'}"
|
|
121
|
+
request["X-Escalated-Source"] = "rails-engine"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_response(response)
|
|
125
|
+
body = parse_body(response.body)
|
|
126
|
+
|
|
127
|
+
case response.code.to_i
|
|
128
|
+
when 200..299
|
|
129
|
+
body
|
|
130
|
+
when 401
|
|
131
|
+
raise AuthenticationError.new(
|
|
132
|
+
"Authentication failed. Check your Escalated API key.",
|
|
133
|
+
status: 401, body: body
|
|
134
|
+
)
|
|
135
|
+
when 429
|
|
136
|
+
raise RateLimitError.new(
|
|
137
|
+
"Rate limit exceeded. Retry after #{response['Retry-After'] || '60'} seconds.",
|
|
138
|
+
status: 429, body: body
|
|
139
|
+
)
|
|
140
|
+
when 400..499
|
|
141
|
+
raise ApiError.new(
|
|
142
|
+
"Client error: #{body['message'] || response.message}",
|
|
143
|
+
status: response.code.to_i, body: body
|
|
144
|
+
)
|
|
145
|
+
when 500..599
|
|
146
|
+
raise ServerError.new(
|
|
147
|
+
"Server error: #{body['message'] || response.message}",
|
|
148
|
+
status: response.code.to_i, body: body
|
|
149
|
+
)
|
|
150
|
+
else
|
|
151
|
+
raise ApiError.new(
|
|
152
|
+
"Unexpected response: #{response.code} #{response.message}",
|
|
153
|
+
status: response.code.to_i, body: body
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_body(raw)
|
|
159
|
+
return {} if raw.blank?
|
|
160
|
+
JSON.parse(raw)
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
{ "raw" => raw }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|