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,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,6 @@
1
+ class AddIsPinnedToEscalatedReplies < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column Escalated.table_name("replies"), :is_pinned, :boolean, default: false, null: false
4
+ add_index Escalated.table_name("replies"), :is_pinned
5
+ end
6
+ 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