sincerely 1.0.0 → 1.1.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 +4 -4
- data/CHANGELOG.md +31 -0
- data/app/assets/config/sincerely.js +2 -0
- data/app/assets/images/sincerely/arrow_left.svg +1 -0
- data/app/assets/images/sincerely/check.svg +3 -0
- data/app/assets/images/sincerely/chevron_left.svg +1 -0
- data/app/assets/images/sincerely/clock.svg +1 -0
- data/app/assets/images/sincerely/code.svg +1 -0
- data/app/assets/images/sincerely/eye.svg +1 -0
- data/app/assets/images/sincerely/logout.svg +1 -0
- data/app/assets/images/sincerely/menu.svg +1 -0
- data/app/assets/images/sincerely/moon.svg +1 -0
- data/app/assets/images/sincerely/pencil.svg +4 -0
- data/app/assets/images/sincerely/plus.svg +3 -0
- data/app/assets/images/sincerely/progress_spinner.svg +3 -0
- data/app/assets/images/sincerely/spinner.svg +3 -0
- data/app/assets/images/sincerely/sun.svg +1 -0
- data/app/assets/images/sincerely/warning_circle.svg +4 -0
- data/app/assets/javascripts/sincerely/charts.js +115 -0
- data/app/assets/javascripts/sincerely/clickable_rows.js +29 -0
- data/app/assets/javascripts/sincerely/layout.js +104 -0
- data/app/assets/javascripts/sincerely/send_form.js +243 -0
- data/app/assets/javascripts/sincerely/template_form.js +44 -0
- data/app/assets/javascripts/sincerely/template_preview.js +12 -0
- data/app/assets/javascripts/sincerely/theme_init.js +9 -0
- data/app/assets/stylesheets/sincerely/charts.css +250 -0
- data/app/assets/stylesheets/sincerely/components.css +632 -0
- data/app/assets/stylesheets/sincerely/layout.css +233 -0
- data/app/assets/stylesheets/sincerely/navigation.css +198 -0
- data/app/assets/stylesheets/sincerely/send_form.css +287 -0
- data/app/assets/stylesheets/sincerely/template_form.css +74 -0
- data/app/assets/stylesheets/sincerely/template_preview.css +75 -0
- data/app/assets/stylesheets/sincerely/variables.css +88 -0
- data/app/controllers/sincerely/application_controller.rb +3 -72
- data/app/controllers/sincerely/concerns/authorization.rb +21 -0
- data/app/controllers/sincerely/concerns/delivery_metrics.rb +51 -0
- data/app/controllers/sincerely/concerns/event_filtering.rb +58 -0
- data/app/controllers/sincerely/concerns/event_timeline.rb +41 -0
- data/app/controllers/sincerely/concerns/notification_timeline.rb +73 -0
- data/app/controllers/sincerely/concerns/notifications.rb +47 -0
- data/app/controllers/sincerely/concerns/pagination.rb +39 -0
- data/app/controllers/sincerely/concerns/periods.rb +40 -0
- data/app/controllers/sincerely/dashboard_controller.rb +30 -88
- data/app/controllers/sincerely/delivery_events_controller.rb +13 -21
- data/app/controllers/sincerely/engagement_events_controller.rb +13 -22
- data/app/controllers/sincerely/notifications_controller.rb +68 -49
- data/app/controllers/sincerely/send_controller.rb +82 -67
- data/app/controllers/sincerely/templates_controller.rb +37 -8
- data/app/helpers/sincerely/application_helper.rb +22 -0
- data/app/helpers/sincerely/chart_helper.rb +79 -0
- data/app/views/layouts/sincerely/application.html.erb +24 -528
- data/app/views/sincerely/dashboard/index.html.erb +25 -256
- data/app/views/sincerely/delivery_events/index.html.erb +6 -38
- data/app/views/sincerely/engagement_events/index.html.erb +6 -38
- data/app/views/sincerely/notifications/index.html.erb +7 -23
- data/app/views/sincerely/notifications/show.html.erb +4 -4
- data/app/views/sincerely/send/new.html.erb +8 -522
- data/app/views/sincerely/shared/_event.html.erb +21 -0
- data/app/views/sincerely/shared/_line_chart.html.erb +38 -0
- data/app/views/sincerely/shared/_notification_type_filter.html.erb +9 -0
- data/app/views/sincerely/shared/_status_option.html.erb +1 -0
- data/app/views/sincerely/shared/_template_option.html.erb +3 -0
- data/app/views/sincerely/templates/_form.html.erb +8 -107
- data/app/views/sincerely/templates/edit.html.erb +2 -2
- data/app/views/sincerely/templates/index.html.erb +7 -15
- data/app/views/sincerely/templates/new.html.erb +2 -2
- data/app/views/sincerely/templates/preview.html.erb +7 -26
- data/app/views/sincerely/templates/show.html.erb +3 -3
- data/lib/config/sincerely_config.rb +2 -2
- data/lib/sincerely/delivery_systems/email_aws_ses.rb +2 -2
- data/lib/sincerely/engine.rb +4 -0
- data/lib/sincerely/mixins/notification_model.rb +5 -1
- data/lib/sincerely/services/process_delivery_event.rb +9 -7
- data/lib/sincerely/templates/notification_template.rb +1 -1
- data/lib/sincerely/version.rb +1 -1
- metadata +52 -4
- data/sincerely.gemspec +0 -37
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
.preview-tabs {
|
|
2
|
+
display: flex;
|
|
3
|
+
gap: 0;
|
|
4
|
+
margin-bottom: -1px;
|
|
5
|
+
position: relative;
|
|
6
|
+
z-index: 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.preview-tab {
|
|
10
|
+
padding: 0.5rem 1rem;
|
|
11
|
+
background: var(--bg-secondary);
|
|
12
|
+
border: 1px solid var(--border-color);
|
|
13
|
+
border-bottom: none;
|
|
14
|
+
border-radius: 0.375rem 0.375rem 0 0;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
font-size: 0.8125rem;
|
|
17
|
+
font-weight: 500;
|
|
18
|
+
color: var(--text-secondary);
|
|
19
|
+
transition: all 0.15s;
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: 0.375rem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.preview-tab:hover {
|
|
26
|
+
color: var(--text-primary);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.preview-tab.active {
|
|
30
|
+
background: var(--bg-primary);
|
|
31
|
+
color: var(--primary);
|
|
32
|
+
border-bottom: 1px solid var(--bg-primary);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.preview-tab svg {
|
|
36
|
+
width: 0.875rem;
|
|
37
|
+
height: 0.875rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.preview-container {
|
|
41
|
+
position: relative;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.preview-panel {
|
|
45
|
+
display: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.preview-panel.active {
|
|
49
|
+
display: block;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.preview-frame {
|
|
53
|
+
width: 100%;
|
|
54
|
+
min-height: 300px;
|
|
55
|
+
border: 1px solid var(--border-color);
|
|
56
|
+
border-radius: 0 0.375rem 0.375rem 0.375rem;
|
|
57
|
+
background: #ffffff;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.preview-text {
|
|
61
|
+
width: 100%;
|
|
62
|
+
min-height: 200px;
|
|
63
|
+
padding: 1rem;
|
|
64
|
+
border: 1px solid var(--border-color);
|
|
65
|
+
border-radius: 0 0.375rem 0.375rem 0.375rem;
|
|
66
|
+
background: var(--bg-primary);
|
|
67
|
+
font-family: 'SF Mono', Consolas, monospace;
|
|
68
|
+
font-size: 0.8125rem;
|
|
69
|
+
white-space: pre-wrap;
|
|
70
|
+
word-break: break-word;
|
|
71
|
+
color: var(--text-primary);
|
|
72
|
+
margin: 0;
|
|
73
|
+
overflow: auto;
|
|
74
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
body {
|
|
2
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
background: #f5f5f5;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.preview-header {
|
|
9
|
+
background: #1f2937;
|
|
10
|
+
color: white;
|
|
11
|
+
padding: 1rem 2rem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.preview-header h1 {
|
|
15
|
+
margin: 0;
|
|
16
|
+
font-size: 1.25rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.preview-meta {
|
|
20
|
+
margin-top: 0.5rem;
|
|
21
|
+
font-size: 0.875rem;
|
|
22
|
+
opacity: 0.8;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.preview-tabs {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 0;
|
|
28
|
+
background: #374151;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.preview-tab {
|
|
32
|
+
padding: 0.75rem 1.5rem;
|
|
33
|
+
color: #9ca3af;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
border: none;
|
|
36
|
+
background: none;
|
|
37
|
+
font-size: 0.875rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.preview-tab.active {
|
|
41
|
+
color: white;
|
|
42
|
+
background: #4f46e5;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.preview-content {
|
|
46
|
+
padding: 2rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.preview-frame {
|
|
50
|
+
background: white;
|
|
51
|
+
border-radius: 8px;
|
|
52
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.preview-frame-html {
|
|
57
|
+
padding: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.preview-frame-html iframe {
|
|
61
|
+
width: 100%;
|
|
62
|
+
height: 600px;
|
|
63
|
+
border: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.preview-frame--hidden {
|
|
67
|
+
display: none;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.preview-frame-text {
|
|
71
|
+
padding: 2rem;
|
|
72
|
+
white-space: pre-wrap;
|
|
73
|
+
font-family: monospace;
|
|
74
|
+
background: #fafafa;
|
|
75
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--primary: #d4183d;
|
|
3
|
+
--primary-dark: #70041e;
|
|
4
|
+
--success: #8acb88;
|
|
5
|
+
--warning: #78c6cc;
|
|
6
|
+
--danger: #d4183d;
|
|
7
|
+
--gray-50: #f9fafb;
|
|
8
|
+
--gray-100: #f3f4f6;
|
|
9
|
+
--gray-200: #e5e7eb;
|
|
10
|
+
--gray-300: #d1d5db;
|
|
11
|
+
--gray-500: #6b7280;
|
|
12
|
+
--gray-700: #374151;
|
|
13
|
+
--gray-900: #111827;
|
|
14
|
+
--bg-primary: #ffffff;
|
|
15
|
+
--bg-secondary: #e6e6e6;
|
|
16
|
+
--text-primary: #70041e;
|
|
17
|
+
--text-secondary: #717182;
|
|
18
|
+
--border-color: #e5e7eb;
|
|
19
|
+
|
|
20
|
+
--success-bg: #d1fae5;
|
|
21
|
+
--success-text: #047857;
|
|
22
|
+
--warning-bg: #fef3c7;
|
|
23
|
+
--warning-text: #b45309;
|
|
24
|
+
--danger-bg: #fee2e2;
|
|
25
|
+
--danger-text: #dc2626;
|
|
26
|
+
--danger-border: #fecaca;
|
|
27
|
+
--info-bg: #dbeafe;
|
|
28
|
+
--info-text: #1d4ed8;
|
|
29
|
+
--purple-bg: #ddd6fe;
|
|
30
|
+
--purple-text: #7c3aed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
[data-theme="dark"] {
|
|
34
|
+
--primary: #d4183d;
|
|
35
|
+
--primary-dark: #490c13;
|
|
36
|
+
--gray-50: #1f2937;
|
|
37
|
+
--gray-100: #374151;
|
|
38
|
+
--gray-200: #4b5563;
|
|
39
|
+
--gray-300: #6b7280;
|
|
40
|
+
--gray-500: #9ca3af;
|
|
41
|
+
--gray-700: #d1d5db;
|
|
42
|
+
--gray-900: #f9fafb;
|
|
43
|
+
--bg-primary: #1e1e1e;
|
|
44
|
+
/* --bg-primary: #9393a1; */
|
|
45
|
+
--bg-secondary: #303030;
|
|
46
|
+
/* --bg-secondary: #1e1e1e; */
|
|
47
|
+
--text-primary: #f9fafb;
|
|
48
|
+
--text-secondary: #9ca3af;
|
|
49
|
+
--border-color: #374151;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
* {
|
|
53
|
+
box-sizing: border-box;
|
|
54
|
+
margin: 0;
|
|
55
|
+
padding: 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
html,
|
|
59
|
+
body {
|
|
60
|
+
height: 100%;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
body {
|
|
64
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
65
|
+
background: var(--bg-secondary);
|
|
66
|
+
color: var(--text-primary);
|
|
67
|
+
line-height: 1.5;
|
|
68
|
+
transition: background-color 0.3s, color 0.3s;
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@media (max-width: 768px) {
|
|
75
|
+
body {
|
|
76
|
+
overflow: auto;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
a {
|
|
81
|
+
color: var(--primary);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.container {
|
|
85
|
+
max-width: 1280px;
|
|
86
|
+
margin: 0 auto;
|
|
87
|
+
padding: 0 1rem;
|
|
88
|
+
}
|
|
@@ -2,80 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
|
+
include Concerns::Authorization
|
|
6
|
+
include Concerns::Notifications
|
|
7
|
+
|
|
5
8
|
protect_from_forgery with: :exception
|
|
6
9
|
|
|
7
10
|
layout 'sincerely/application'
|
|
8
|
-
|
|
9
|
-
before_action :authenticate_user!
|
|
10
|
-
|
|
11
|
-
helper_method :notification_model
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
-
|
|
15
|
-
def authenticate_user!
|
|
16
|
-
return unless Sincerely.authenticate_with
|
|
17
|
-
|
|
18
|
-
instance_exec(&Sincerely.authenticate_with)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def notification_model
|
|
22
|
-
@notification_model ||= Sincerely.notification_model
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def paginate(collection, per_page: 25)
|
|
26
|
-
page = (params[:page] || 1).to_i
|
|
27
|
-
offset = (page - 1) * per_page
|
|
28
|
-
total = collection.count
|
|
29
|
-
|
|
30
|
-
{
|
|
31
|
-
records: collection.offset(offset).limit(per_page),
|
|
32
|
-
total_count: total,
|
|
33
|
-
current_page: page,
|
|
34
|
-
per_page:,
|
|
35
|
-
total_pages: (total.to_f / per_page).ceil
|
|
36
|
-
}
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def time_filter_start
|
|
40
|
-
period = params[:period]
|
|
41
|
-
period = '24h' unless %w[1h 24h 7d 30d 3m all].include?(period)
|
|
42
|
-
|
|
43
|
-
case period
|
|
44
|
-
when '1h' then 1.hour.ago
|
|
45
|
-
when '24h' then 24.hours.ago
|
|
46
|
-
when '7d' then 7.days.ago
|
|
47
|
-
when '30d' then 30.days.ago
|
|
48
|
-
when '3m' then 3.months.ago
|
|
49
|
-
when 'all' then nil
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def apply_time_filter(collection, column: :created_at)
|
|
54
|
-
start_time = time_filter_start
|
|
55
|
-
return collection unless start_time
|
|
56
|
-
|
|
57
|
-
collection.where("#{column} >= ?", start_time)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def apply_notification_filter(scope)
|
|
61
|
-
filter = Sincerely.config.filter_notifications_by
|
|
62
|
-
return scope unless filter.respond_to?(:call)
|
|
63
|
-
|
|
64
|
-
conditions = instance_exec(&filter)
|
|
65
|
-
return scope if conditions.blank?
|
|
66
|
-
|
|
67
|
-
scope.where(conditions)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def apply_event_filter(scope)
|
|
71
|
-
filter = Sincerely.config.filter_notifications_by
|
|
72
|
-
return scope unless filter.respond_to?(:call)
|
|
73
|
-
|
|
74
|
-
conditions = instance_exec(&filter)
|
|
75
|
-
return scope if conditions.blank?
|
|
76
|
-
|
|
77
|
-
message_ids = notification_model.where(conditions).where.not(message_id: nil).pluck(:message_id)
|
|
78
|
-
scope.where(message_id: message_ids)
|
|
79
|
-
end
|
|
80
11
|
end
|
|
81
12
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module Authorization
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_action :authenticate_sincerely_user!
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def authenticate_sincerely_user!
|
|
15
|
+
return unless Sincerely.authenticate_with
|
|
16
|
+
|
|
17
|
+
instance_exec(&Sincerely.authenticate_with)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module DeliveryMetrics
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
EXCLUDED_FROM_SENT = %w[draft rejected].freeze
|
|
9
|
+
DELIVERED_STATES = %w[delivered opened clicked].freeze
|
|
10
|
+
ENGAGED_STATES = %w[opened clicked].freeze
|
|
11
|
+
|
|
12
|
+
private_constant :EXCLUDED_FROM_SENT, :DELIVERED_STATES, :ENGAGED_STATES
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def calculate_delivery_rate
|
|
17
|
+
percentage(delivered_count, total_sent)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def calculate_open_rate
|
|
21
|
+
percentage(engaged_count, delivered_count)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def calculate_bounce_rate
|
|
25
|
+
percentage(bounced_count, total_sent)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def total_sent
|
|
29
|
+
@total_sent ||= filtered_notifications.where.not(delivery_state: EXCLUDED_FROM_SENT).count
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delivered_count
|
|
33
|
+
@delivered_count ||= filtered_notifications.where(delivery_state: DELIVERED_STATES).count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def engaged_count
|
|
37
|
+
@engaged_count ||= filtered_notifications.where(delivery_state: ENGAGED_STATES).count
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def bounced_count
|
|
41
|
+
@bounced_count ||= filtered_notifications.where(delivery_state: 'bounced').count
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def percentage(numerator, denominator)
|
|
45
|
+
return 0 unless denominator.positive?
|
|
46
|
+
|
|
47
|
+
(numerator.to_f / denominator * 100).round(1)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module EventFiltering
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def filtered_events
|
|
11
|
+
scope = apply_event_filter(apply_time_filter(event_model, column: :timestamp)).order(timestamp: :desc)
|
|
12
|
+
scope = filter_by_event_type(scope)
|
|
13
|
+
scope = filter_by_recipient(scope)
|
|
14
|
+
filter_by_notification_attributes(scope)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def filter_by_event_type(scope)
|
|
18
|
+
return scope if params[:event_type].blank?
|
|
19
|
+
|
|
20
|
+
scope.where(event_type: params[:event_type])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def filter_by_recipient(scope)
|
|
24
|
+
return scope if params[:recipient].blank?
|
|
25
|
+
|
|
26
|
+
scope.where('recipient LIKE ?', "%#{params[:recipient]}%")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def filter_by_notification_attributes(scope)
|
|
30
|
+
return scope unless params[:template_id].present? || params[:notification_type].present?
|
|
31
|
+
|
|
32
|
+
scope.where(message_id: filtered_message_ids)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def filtered_message_ids
|
|
36
|
+
scope = notification_model.all
|
|
37
|
+
scope = scope.where(template_id: params[:template_id]) if params[:template_id].present?
|
|
38
|
+
scope = scope.where(notification_type: params[:notification_type]) if params[:notification_type].present?
|
|
39
|
+
scope.where.not(message_id: nil).select(:message_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def preload_notifications(events)
|
|
43
|
+
message_ids = events.map(&:message_id).compact.uniq
|
|
44
|
+
return {} if message_ids.empty?
|
|
45
|
+
|
|
46
|
+
notification_model.where(message_id: message_ids).index_by(&:message_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def available_event_types
|
|
50
|
+
event_model.distinct.pluck(:event_type).compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def available_templates
|
|
54
|
+
Sincerely::Templates::NotificationTemplate.all
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module EventTimeline
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def build_event_timeline
|
|
11
|
+
(delivery_events + engagement_events).sort_by { |e| e[:timestamp] }.reverse
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def delivery_events
|
|
15
|
+
events_for_model(Sincerely::DeliveryEvent)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def engagement_events
|
|
19
|
+
events_for_model(Sincerely::EngagementEvent)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def events_for_model(model)
|
|
23
|
+
return [] if @notification.message_id.nil?
|
|
24
|
+
|
|
25
|
+
model.where(message_id: @notification.message_id).map do |event|
|
|
26
|
+
{
|
|
27
|
+
type: event.event_type,
|
|
28
|
+
timestamp: event.timestamp || event.created_at,
|
|
29
|
+
details: format_event_details(event.options)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def format_event_details(data)
|
|
35
|
+
return nil if data.blank?
|
|
36
|
+
|
|
37
|
+
data.is_a?(Hash) ? data.map { |k, v| "#{k}: #{v}" }.join(', ') : data.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module NotificationTimeline
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def build_timeline_data(notifications)
|
|
11
|
+
buckets = generate_time_buckets
|
|
12
|
+
series = build_timeline_series(notifications, buckets)
|
|
13
|
+
labels = buckets.map { |b| b.strftime(timeline_bucket_config[:format]) }
|
|
14
|
+
|
|
15
|
+
{ labels:, series:, buckets: }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_timeline_series(notifications, buckets)
|
|
19
|
+
return {} if buckets.empty?
|
|
20
|
+
|
|
21
|
+
interval = timeline_bucket_config[:interval].to_i
|
|
22
|
+
start_epoch = buckets.first.to_i
|
|
23
|
+
|
|
24
|
+
counts = notifications
|
|
25
|
+
.unscope(:order)
|
|
26
|
+
.group(:delivery_state, bucket_index_sql(start_epoch, interval))
|
|
27
|
+
.count
|
|
28
|
+
|
|
29
|
+
counts.each_with_object({}) do |((state, raw_idx), count), series|
|
|
30
|
+
idx = raw_idx.to_i
|
|
31
|
+
next unless idx >= 0 && idx < buckets.length
|
|
32
|
+
|
|
33
|
+
series[state] ||= Array.new(buckets.length, 0)
|
|
34
|
+
series[state][idx] = count
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def timeline_bucket_config
|
|
39
|
+
@timeline_bucket_config ||= case current_period
|
|
40
|
+
when '1h'
|
|
41
|
+
{ interval: 5.minutes, format: '%H:%M', start: 1.hour.ago }
|
|
42
|
+
when '24h'
|
|
43
|
+
{ interval: 1.hour, format: '%H:%M', start: 24.hours.ago }
|
|
44
|
+
when '7d'
|
|
45
|
+
{ interval: 6.hours, format: '%a %H:%M', start: 7.days.ago }
|
|
46
|
+
when '30d'
|
|
47
|
+
{ interval: 1.day, format: '%b %d', start: 30.days.ago }
|
|
48
|
+
when '3m'
|
|
49
|
+
{ interval: 1.week, format: '%b %d', start: 3.months.ago }
|
|
50
|
+
when 'all'
|
|
51
|
+
oldest = filtered_notifications.minimum(:created_at) || 1.year.ago
|
|
52
|
+
{ interval: 1.month, format: '%b %Y', start: oldest }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def generate_time_buckets
|
|
57
|
+
config = timeline_bucket_config
|
|
58
|
+
interval = config[:interval].to_i
|
|
59
|
+
start_epoch = (config[:start].to_i / interval) * interval
|
|
60
|
+
(start_epoch..Time.current.to_i).step(interval).map { |t| Time.zone.at(t) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def bucket_index_sql(start_epoch, interval)
|
|
64
|
+
epoch_sql = case ActiveRecord::Base.connection.adapter_name.downcase
|
|
65
|
+
when 'postgresql' then 'FLOOR(EXTRACT(EPOCH FROM created_at))'
|
|
66
|
+
when 'mysql2' then 'FLOOR(UNIX_TIMESTAMP(created_at))'
|
|
67
|
+
else "CAST(strftime('%s', created_at) AS INTEGER)"
|
|
68
|
+
end
|
|
69
|
+
Arel.sql("FLOOR((#{epoch_sql} - #{start_epoch}) / #{interval})")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module Notifications
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
helper_method :notification_model
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def notification_model
|
|
15
|
+
@notification_model ||= Sincerely.notification_model
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def apply_notification_filter(scope)
|
|
19
|
+
return scope unless notification_filter_callable?
|
|
20
|
+
|
|
21
|
+
conditions = notification_filter_conditions
|
|
22
|
+
conditions.blank? ? scope : scope.where(conditions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_event_filter(scope)
|
|
26
|
+
return scope unless notification_filter_callable?
|
|
27
|
+
|
|
28
|
+
conditions = notification_filter_conditions
|
|
29
|
+
return scope if conditions.blank?
|
|
30
|
+
|
|
31
|
+
scope.where(message_id: filtered_message_ids_for_events(conditions))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def notification_filter_callable?
|
|
35
|
+
Sincerely.config.filter_notifications_by.respond_to?(:call)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def notification_filter_conditions
|
|
39
|
+
instance_exec(&Sincerely.config.filter_notifications_by)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def filtered_message_ids_for_events(conditions)
|
|
43
|
+
notification_model.where(conditions).where.not(message_id: nil).select(:message_id).distinct
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module Pagination
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
DEFAULT_PER_PAGE = 25
|
|
9
|
+
private_constant :DEFAULT_PER_PAGE
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def paginate(collection, per_page: DEFAULT_PER_PAGE)
|
|
14
|
+
page = current_page
|
|
15
|
+
total = collection.count
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
records: collection.offset(page_offset(page, per_page)).limit(per_page),
|
|
19
|
+
total_count: total,
|
|
20
|
+
current_page: page,
|
|
21
|
+
per_page:,
|
|
22
|
+
total_pages: total_pages(total, per_page)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current_page
|
|
27
|
+
[(params[:page] || 1).to_i, 1].max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def page_offset(page, per_page)
|
|
31
|
+
(page - 1) * per_page
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def total_pages(total, per_page)
|
|
35
|
+
(total.to_f / per_page).ceil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sincerely
|
|
4
|
+
module Concerns
|
|
5
|
+
module Periods
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
VALID_PERIODS = %w[1h 24h 7d 30d 3m all].freeze
|
|
9
|
+
DEFAULT_PERIOD = '24h'
|
|
10
|
+
|
|
11
|
+
PERIOD_DURATIONS = {
|
|
12
|
+
'1h' => 1.hour,
|
|
13
|
+
'24h' => 24.hours,
|
|
14
|
+
'7d' => 7.days,
|
|
15
|
+
'30d' => 30.days,
|
|
16
|
+
'3m' => 3.months,
|
|
17
|
+
'all' => nil
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
private_constant :VALID_PERIODS, :DEFAULT_PERIOD, :PERIOD_DURATIONS
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def current_period
|
|
25
|
+
VALID_PERIODS.include?(params[:period]) ? params[:period] : DEFAULT_PERIOD
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def time_filter_start
|
|
29
|
+
duration = PERIOD_DURATIONS[current_period]
|
|
30
|
+
duration&.ago
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_time_filter(collection, column: :created_at)
|
|
34
|
+
return collection.all unless time_filter_start
|
|
35
|
+
|
|
36
|
+
collection.where("#{column} >= ?", time_filter_start)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|