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
|
@@ -2,111 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class DashboardController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
include Concerns::Periods
|
|
6
|
+
include Concerns::DeliveryMetrics
|
|
7
|
+
include Concerns::NotificationTimeline
|
|
8
8
|
|
|
9
|
+
def index
|
|
9
10
|
@total_notifications = filtered_notifications.count
|
|
10
11
|
@notifications_by_state = filtered_notifications.group(:delivery_state).count
|
|
11
12
|
@recent_notifications = filtered_notifications.order(created_at: :desc).limit(10)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
delivered_count = filtered_notifications.where(delivery_state: %w[delivered opened clicked]).count
|
|
20
|
-
engaged = filtered_notifications.where(delivery_state: %w[opened clicked]).count
|
|
21
|
-
@open_rate = delivered_count.positive? ? (engaged.to_f / delivered_count * 100).round(1) : 0
|
|
22
|
-
|
|
23
|
-
# Recent events (filtered by user's notifications)
|
|
24
|
-
user_message_ids = filtered_notifications.where.not(message_id: nil).pluck(:message_id)
|
|
25
|
-
@recent_delivery_events = apply_time_filter(Sincerely::DeliveryEvent)
|
|
26
|
-
.where(message_id: user_message_ids)
|
|
27
|
-
.order(created_at: :desc).limit(5)
|
|
28
|
-
@recent_engagement_events = apply_time_filter(Sincerely::EngagementEvent)
|
|
29
|
-
.where(message_id: user_message_ids)
|
|
30
|
-
.order(created_at: :desc).limit(5)
|
|
31
|
-
|
|
32
|
-
# Bounce rate
|
|
33
|
-
bounced = filtered_notifications.where(delivery_state: 'bounced').count
|
|
34
|
-
@bounce_rate = total_sent.positive? ? (bounced.to_f / total_sent * 100).round(1) : 0
|
|
35
|
-
|
|
36
|
-
# Current period for display
|
|
37
|
-
@current_period = params[:period] || '24h'
|
|
38
|
-
|
|
13
|
+
@delivery_rate = calculate_delivery_rate
|
|
14
|
+
@open_rate = calculate_open_rate
|
|
15
|
+
@bounce_rate = calculate_bounce_rate
|
|
16
|
+
@recent_delivery_events = recent_delivery_events
|
|
17
|
+
@recent_engagement_events = recent_engagement_events
|
|
18
|
+
@notifications_by_message_id = preload_notifications_for_events
|
|
19
|
+
@current_period = current_period
|
|
39
20
|
@notifications_timeline = build_timeline_data(filtered_notifications)
|
|
40
21
|
end
|
|
41
22
|
|
|
42
23
|
private
|
|
43
24
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
buckets = generate_time_buckets(bucket_config)
|
|
47
|
-
interval = bucket_config[:interval]
|
|
48
|
-
series = {}
|
|
49
|
-
|
|
50
|
-
notifications.find_each do |notification|
|
|
51
|
-
bucket_index = find_bucket_index(notification.created_at, buckets, interval)
|
|
52
|
-
next if bucket_index.nil?
|
|
53
|
-
|
|
54
|
-
state = notification.delivery_state
|
|
55
|
-
series[state] ||= Array.new(buckets.length, 0)
|
|
56
|
-
series[state][bucket_index] += 1
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
labels = buckets.map { |b| format_bucket_label(b, bucket_config) }
|
|
60
|
-
|
|
61
|
-
{ labels:, series:, buckets: }
|
|
25
|
+
def filtered_notifications
|
|
26
|
+
@filtered_notifications ||= apply_notification_filter(apply_time_filter(notification_model))
|
|
62
27
|
end
|
|
63
28
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
period = '24h' unless %w[1h 24h 7d 30d 3m all].include?(period)
|
|
67
|
-
|
|
68
|
-
case period
|
|
69
|
-
when '1h'
|
|
70
|
-
{ interval: 5.minutes, format: '%H:%M', start: 1.hour.ago }
|
|
71
|
-
when '24h'
|
|
72
|
-
{ interval: 1.hour, format: '%H:%M', start: 24.hours.ago }
|
|
73
|
-
when '7d'
|
|
74
|
-
{ interval: 6.hours, format: '%a %H:%M', start: 7.days.ago }
|
|
75
|
-
when '30d'
|
|
76
|
-
{ interval: 1.day, format: '%b %d', start: 30.days.ago }
|
|
77
|
-
when '3m'
|
|
78
|
-
{ interval: 1.week, format: '%b %d', start: 3.months.ago }
|
|
79
|
-
when 'all'
|
|
80
|
-
{ interval: 1.month, format: '%b %Y', start: 1.year.ago }
|
|
81
|
-
end
|
|
29
|
+
def user_message_ids_subquery
|
|
30
|
+
filtered_notifications.where.not(message_id: nil).select(:message_id)
|
|
82
31
|
end
|
|
83
32
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
while current <= now
|
|
90
|
-
buckets << current
|
|
91
|
-
current += config[:interval]
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
buckets
|
|
33
|
+
def recent_delivery_events
|
|
34
|
+
apply_time_filter(Sincerely::DeliveryEvent, column: :timestamp)
|
|
35
|
+
.where(message_id: user_message_ids_subquery)
|
|
36
|
+
.order(timestamp: :desc)
|
|
37
|
+
.limit(5)
|
|
95
38
|
end
|
|
96
39
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return index if time >= bucket_start && time < bucket_end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
buckets.length - 1 if time >= buckets.last && time < buckets.last + interval
|
|
40
|
+
def recent_engagement_events
|
|
41
|
+
apply_time_filter(Sincerely::EngagementEvent, column: :timestamp)
|
|
42
|
+
.where(message_id: user_message_ids_subquery)
|
|
43
|
+
.order(timestamp: :desc)
|
|
44
|
+
.limit(5)
|
|
106
45
|
end
|
|
107
46
|
|
|
108
|
-
def
|
|
109
|
-
|
|
47
|
+
def preload_notifications_for_events
|
|
48
|
+
message_ids = (@recent_delivery_events + @recent_engagement_events).map(&:message_id).compact.uniq
|
|
49
|
+
return {} if message_ids.empty?
|
|
50
|
+
|
|
51
|
+
notification_model.where(message_id: message_ids).index_by(&:message_id)
|
|
110
52
|
end
|
|
111
53
|
end
|
|
112
54
|
end
|
|
@@ -2,30 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class DeliveryEventsController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@events = @events.where(event_type: params[:event_type]) if params[:event_type].present?
|
|
10
|
-
@events = @events.where('recipient LIKE ?', "%#{params[:recipient]}%") if params[:recipient].present?
|
|
11
|
-
|
|
12
|
-
if params[:template_id].present? || params[:notification_type].present?
|
|
13
|
-
notification_scope = notification_model.all
|
|
14
|
-
if params[:template_id].present?
|
|
15
|
-
notification_scope = notification_scope.where(template_id: params[:template_id])
|
|
16
|
-
end
|
|
17
|
-
if params[:notification_type].present?
|
|
18
|
-
notification_scope = notification_scope.where(notification_type: params[:notification_type])
|
|
19
|
-
end
|
|
20
|
-
message_ids = notification_scope.pluck(:message_id).compact
|
|
21
|
-
@events = @events.where(message_id: message_ids)
|
|
22
|
-
end
|
|
5
|
+
include Concerns::Pagination
|
|
6
|
+
include Concerns::Periods
|
|
7
|
+
include Concerns::EventFiltering
|
|
23
8
|
|
|
24
|
-
|
|
9
|
+
def index
|
|
10
|
+
@pagination = paginate(filtered_events)
|
|
25
11
|
@events = @pagination[:records]
|
|
12
|
+
@notifications_by_message_id = preload_notifications(@events)
|
|
13
|
+
@event_types = available_event_types
|
|
14
|
+
@templates = available_templates
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
def event_model
|
|
20
|
+
Sincerely::DeliveryEvent
|
|
29
21
|
end
|
|
30
22
|
end
|
|
31
23
|
end
|
|
@@ -2,31 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class EngagementEventsController < ApplicationController
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@events = @events.where(event_type: params[:event_type]) if params[:event_type].present?
|
|
10
|
-
@events = @events.where('recipient LIKE ?', "%#{params[:recipient]}%") if params[:recipient].present?
|
|
11
|
-
|
|
12
|
-
# Filter by notification properties via message_id
|
|
13
|
-
if params[:template_id].present? || params[:notification_type].present?
|
|
14
|
-
notification_scope = notification_model.all
|
|
15
|
-
if params[:template_id].present?
|
|
16
|
-
notification_scope = notification_scope.where(template_id: params[:template_id])
|
|
17
|
-
end
|
|
18
|
-
if params[:notification_type].present?
|
|
19
|
-
notification_scope = notification_scope.where(notification_type: params[:notification_type])
|
|
20
|
-
end
|
|
21
|
-
message_ids = notification_scope.pluck(:message_id).compact
|
|
22
|
-
@events = @events.where(message_id: message_ids)
|
|
23
|
-
end
|
|
5
|
+
include Concerns::Pagination
|
|
6
|
+
include Concerns::Periods
|
|
7
|
+
include Concerns::EventFiltering
|
|
24
8
|
|
|
25
|
-
|
|
9
|
+
def index
|
|
10
|
+
@pagination = paginate(filtered_events)
|
|
26
11
|
@events = @pagination[:records]
|
|
12
|
+
@notifications_by_message_id = preload_notifications(@events)
|
|
13
|
+
@event_types = available_event_types
|
|
14
|
+
@templates = available_templates
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
def event_model
|
|
20
|
+
Sincerely::EngagementEvent
|
|
30
21
|
end
|
|
31
22
|
end
|
|
32
23
|
end
|
|
@@ -2,68 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class NotificationsController < ApplicationController
|
|
5
|
+
include Concerns::Pagination
|
|
6
|
+
include Concerns::Periods
|
|
7
|
+
include Concerns::EventTimeline
|
|
8
|
+
|
|
5
9
|
def index
|
|
6
|
-
@
|
|
7
|
-
|
|
8
|
-
# Apply host app filter (e.g., filter by logged-in user's email)
|
|
9
|
-
@notifications = apply_notification_filter(@notifications)
|
|
10
|
-
|
|
11
|
-
# Apply filters
|
|
12
|
-
@notifications = @notifications.where(delivery_state: params[:status]) if params[:status].present?
|
|
13
|
-
@notifications = @notifications.where(template_id: params[:template_id]) if params[:template_id].present?
|
|
14
|
-
if params[:recipient].present?
|
|
15
|
-
@notifications = @notifications.where('recipient LIKE ?',
|
|
16
|
-
"%#{params[:recipient]}%")
|
|
17
|
-
end
|
|
18
|
-
if params[:notification_type].present?
|
|
19
|
-
@notifications = @notifications.where(notification_type: params[:notification_type])
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
@notifications = @notifications.where(created_at: Date.parse(params[:date_from])..) if params[:date_from].present?
|
|
23
|
-
if params[:date_to].present?
|
|
24
|
-
@notifications = @notifications.where(created_at: ..Date.parse(params[:date_to]).end_of_day)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
@pagination = paginate(@notifications)
|
|
10
|
+
@pagination = paginate(filtered_notifications)
|
|
28
11
|
@notifications = @pagination[:records]
|
|
29
|
-
@templates =
|
|
30
|
-
@states =
|
|
12
|
+
@templates = available_templates
|
|
13
|
+
@states = available_states
|
|
31
14
|
end
|
|
32
15
|
|
|
33
16
|
def show
|
|
34
|
-
@notification =
|
|
17
|
+
@notification = find_notification
|
|
35
18
|
@template = @notification.template
|
|
36
|
-
@timeline = build_event_timeline
|
|
19
|
+
@timeline = build_event_timeline
|
|
37
20
|
end
|
|
38
21
|
|
|
39
22
|
private
|
|
40
23
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
24
|
+
def filtered_notifications
|
|
25
|
+
scope = base_notifications_scope
|
|
26
|
+
scope = filter_by_status(scope)
|
|
27
|
+
scope = filter_by_template(scope)
|
|
28
|
+
scope = filter_by_recipient(scope)
|
|
29
|
+
scope = filter_by_notification_type(scope)
|
|
30
|
+
filter_by_date_range(scope)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def base_notifications_scope
|
|
34
|
+
apply_notification_filter(apply_time_filter(notification_model)).includes(:template).order(created_at: :desc)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def filter_by_status(scope)
|
|
38
|
+
return scope if params[:status].blank?
|
|
39
|
+
|
|
40
|
+
scope.where(delivery_state: params[:status])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def filter_by_template(scope)
|
|
44
|
+
return scope if params[:template_id].blank?
|
|
45
|
+
|
|
46
|
+
scope.where(template_id: params[:template_id])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def filter_by_recipient(scope)
|
|
50
|
+
return scope if params[:recipient].blank?
|
|
51
|
+
|
|
52
|
+
scope.where('recipient LIKE ?', "%#{params[:recipient]}%")
|
|
61
53
|
end
|
|
62
54
|
|
|
63
|
-
def
|
|
64
|
-
return
|
|
55
|
+
def filter_by_notification_type(scope)
|
|
56
|
+
return scope if params[:notification_type].blank?
|
|
57
|
+
|
|
58
|
+
scope.where(notification_type: params[:notification_type])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def filter_by_date_range(scope)
|
|
62
|
+
date_from = safe_parse_date(params[:date_from])
|
|
63
|
+
date_to = safe_parse_date(params[:date_to])
|
|
64
|
+
|
|
65
|
+
scope = scope.where(created_at: date_from..) if date_from
|
|
66
|
+
scope = scope.where(created_at: ..date_to.end_of_day) if date_to
|
|
67
|
+
scope
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def safe_parse_date(value)
|
|
71
|
+
Date.iso8601(value) if value.present?
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def find_notification
|
|
77
|
+
apply_notification_filter(notification_model).find(params[:id])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def available_templates
|
|
81
|
+
Sincerely::Templates::NotificationTemplate.all
|
|
82
|
+
end
|
|
65
83
|
|
|
66
|
-
|
|
84
|
+
def available_states
|
|
85
|
+
notification_model.aasm.states.map(&:name)
|
|
67
86
|
end
|
|
68
87
|
end
|
|
69
88
|
end
|
|
@@ -3,103 +3,118 @@
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class SendController < ApplicationController
|
|
5
5
|
MAX_RECIPIENTS = 50
|
|
6
|
+
EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\z/
|
|
7
|
+
RECIPIENT_DELIMITER = /[\s,;\n]+/
|
|
8
|
+
|
|
9
|
+
private_constant :MAX_RECIPIENTS, :EMAIL_PATTERN, :RECIPIENT_DELIMITER
|
|
6
10
|
|
|
7
11
|
def new
|
|
8
|
-
@templates =
|
|
12
|
+
@templates = available_templates
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def create
|
|
12
|
-
recipients
|
|
16
|
+
return render_error('No valid recipients provided') if recipients.empty?
|
|
17
|
+
return render_error("Maximum #{MAX_RECIPIENTS} recipients allowed") if recipients.size > MAX_RECIPIENTS
|
|
18
|
+
return render_error('Template not found') unless template
|
|
13
19
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
17
|
-
end
|
|
20
|
+
render json: send_results
|
|
21
|
+
end
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
status: :unprocessable_entity
|
|
22
|
-
return
|
|
23
|
-
end
|
|
23
|
+
def template_variables
|
|
24
|
+
return render json: { variables: [] } unless template
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
render json: { success: false, error: 'Template not found' }, status: :unprocessable_entity
|
|
28
|
-
return
|
|
29
|
-
end
|
|
26
|
+
render json: { variables: extract_liquid_variables.uniq.sort }
|
|
27
|
+
end
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
private
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
def available_templates
|
|
32
|
+
Sincerely::Templates::NotificationTemplate.order(:name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def template
|
|
36
|
+
@template ||= Sincerely::Templates::NotificationTemplate.find_by(id: params[:template_id] || params[:id])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def recipients
|
|
40
|
+
@recipients ||= parse_recipients(params[:recipients])
|
|
41
|
+
end
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
notification.deliver
|
|
46
|
-
results[:sent] += 1
|
|
47
|
-
rescue StandardError => e
|
|
48
|
-
results[:failed] += 1
|
|
49
|
-
results[:errors] << { recipient:, error: e.message }
|
|
50
|
-
end
|
|
51
|
-
else
|
|
52
|
-
results[:failed] += 1
|
|
53
|
-
results[:errors] << { recipient:, error: notification.errors.full_messages.join(', ') }
|
|
54
|
-
end
|
|
43
|
+
def template_data
|
|
44
|
+
@template_data ||= begin
|
|
45
|
+
raw = params[:template_data]
|
|
46
|
+
data = case raw
|
|
47
|
+
when ActionController::Parameters then raw.to_unsafe_h
|
|
48
|
+
when Hash then raw
|
|
49
|
+
else {}
|
|
50
|
+
end
|
|
51
|
+
allowed_keys = extract_liquid_variables.uniq
|
|
52
|
+
data.slice(*allowed_keys)
|
|
55
53
|
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def send_results
|
|
57
|
+
results = { sent: 0, failed: 0, errors: [] }
|
|
58
|
+
|
|
59
|
+
recipients.each { |recipient| process_recipient(recipient, results) }
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
success: true,
|
|
59
|
-
sent: results[:sent],
|
|
60
|
-
failed: results[:failed],
|
|
61
|
-
errors: results[:errors].first(5) # Limit error details
|
|
62
|
-
}
|
|
61
|
+
{ success: true, sent: results[:sent], failed: results[:failed], errors: results[:errors].first(5) }
|
|
63
62
|
end
|
|
64
63
|
|
|
65
|
-
def
|
|
66
|
-
|
|
64
|
+
def process_recipient(recipient, results)
|
|
65
|
+
notification = build_notification(recipient)
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
if notification.save
|
|
68
|
+
deliver_notification(notification, recipient, results)
|
|
69
|
+
else
|
|
70
|
+
record_failure(results, recipient, notification.errors.full_messages.join(', '))
|
|
71
71
|
end
|
|
72
|
+
end
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
def build_notification(recipient)
|
|
75
|
+
notification_model.new(
|
|
76
|
+
recipient:,
|
|
77
|
+
notification_type: 'email',
|
|
78
|
+
template_id: template.id,
|
|
79
|
+
delivery_options: { template_data: }
|
|
80
|
+
)
|
|
75
81
|
end
|
|
76
82
|
|
|
77
|
-
|
|
83
|
+
def deliver_notification(notification, recipient, results)
|
|
84
|
+
notification.deliver
|
|
85
|
+
results[:sent] += 1
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
Rails.logger.error("Sincerely delivery failed for #{recipient}: #{e.class}: #{e.message}")
|
|
88
|
+
record_failure(results, recipient, 'Delivery failed')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def record_failure(results, recipient, error)
|
|
92
|
+
results[:failed] += 1
|
|
93
|
+
results[:errors] << { recipient:, error: }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_error(message)
|
|
97
|
+
render json: { success: false, error: message }, status: :unprocessable_entity
|
|
98
|
+
end
|
|
78
99
|
|
|
79
100
|
def parse_recipients(input)
|
|
80
101
|
return [] if input.blank?
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
input.split(/[\s,;\n]+/)
|
|
103
|
+
input.split(RECIPIENT_DELIMITER)
|
|
84
104
|
.map(&:strip)
|
|
85
105
|
.compact_blank
|
|
86
|
-
.grep(
|
|
106
|
+
.grep(EMAIL_PATTERN)
|
|
87
107
|
.uniq
|
|
88
|
-
.first(MAX_RECIPIENTS)
|
|
89
108
|
end
|
|
90
109
|
|
|
91
|
-
def extract_liquid_variables
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
].compact.join(' ')
|
|
110
|
+
def extract_liquid_variables
|
|
111
|
+
template_content.scan(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*?)[\s|}]/)
|
|
112
|
+
.flatten
|
|
113
|
+
.map { |v| v.split('.').first }
|
|
114
|
+
end
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.flatten
|
|
101
|
-
.map { |v| v.split('.').first } # Get root variable name
|
|
102
|
-
.uniq
|
|
116
|
+
def template_content
|
|
117
|
+
[template.subject, template.html_content, template.text_content].compact.join(' ')
|
|
103
118
|
end
|
|
104
119
|
end
|
|
105
120
|
end
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Sincerely
|
|
4
4
|
class TemplatesController < ApplicationController
|
|
5
|
+
include Concerns::Pagination
|
|
6
|
+
|
|
5
7
|
before_action :set_template, only: %i[show edit update preview]
|
|
6
8
|
|
|
7
9
|
def index
|
|
8
|
-
@pagination = paginate(
|
|
10
|
+
@pagination = paginate(all_templates)
|
|
9
11
|
@templates = @pagination[:records]
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def show
|
|
13
|
-
@notification_count =
|
|
15
|
+
@notification_count = template_notification_count
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def new
|
|
@@ -38,14 +40,13 @@ module Sincerely
|
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def preview
|
|
41
|
-
|
|
42
|
-
@rendered_subject = @template.renderer.render(@template.subject || '', { 'template_data' => sample_data })
|
|
43
|
-
@rendered_html = @template.render(:html, { 'template_data' => sample_data })
|
|
44
|
-
@rendered_text = @template.render(:text, { 'template_data' => sample_data })
|
|
45
|
-
|
|
46
|
-
render layout: false
|
|
43
|
+
render_preview
|
|
47
44
|
rescue JSON::ParserError
|
|
48
45
|
render plain: 'Invalid JSON in sample data', status: :bad_request
|
|
46
|
+
rescue ArgumentError => e
|
|
47
|
+
render plain: e.message, status: :bad_request
|
|
48
|
+
rescue Liquid::Error => e
|
|
49
|
+
render plain: "Template rendering error: #{e.message}", status: :unprocessable_entity
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
private
|
|
@@ -57,5 +58,33 @@ module Sincerely
|
|
|
57
58
|
def template_params
|
|
58
59
|
params.require(:template).permit(:name, :subject, :sender, :html_content, :text_content)
|
|
59
60
|
end
|
|
61
|
+
|
|
62
|
+
def all_templates
|
|
63
|
+
Sincerely::Templates::NotificationTemplate.order(created_at: :desc)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def template_notification_count
|
|
67
|
+
notification_model.where(template_id: @template.id).count
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_preview
|
|
71
|
+
@rendered_subject = @template.renderer.render(@template.subject || '', preview_context)
|
|
72
|
+
@rendered_html = @template.render(:html, preview_context)
|
|
73
|
+
@rendered_text = @template.render(:text, preview_context)
|
|
74
|
+
render layout: false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def preview_context
|
|
78
|
+
{ 'template_data' => parsed_sample_data }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parsed_sample_data
|
|
82
|
+
return {} if params[:sample_data].blank?
|
|
83
|
+
|
|
84
|
+
parsed = JSON.parse(params[:sample_data])
|
|
85
|
+
raise ArgumentError, 'Sample data must be a JSON object' unless parsed.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
parsed
|
|
88
|
+
end
|
|
60
89
|
end
|
|
61
90
|
end
|
|
@@ -17,12 +17,34 @@ module Sincerely
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
def inline_svg(name)
|
|
21
|
+
return '' unless name.match?(%r{\A[a-z0-9_\-/]+\z}i) && name.exclude?('..')
|
|
22
|
+
|
|
23
|
+
base = Sincerely::Engine.root.join('app', 'assets', 'images')
|
|
24
|
+
path = base.join("#{name}.svg")
|
|
25
|
+
return '' unless path.to_s.start_with?(base.to_s) && path.exist?
|
|
26
|
+
|
|
27
|
+
raw path.read # rubocop:disable Rails/OutputSafety -- SVGs are read from the engine's own assets, not user input
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
def format_timestamp(time)
|
|
21
31
|
return '-' unless time
|
|
22
32
|
|
|
23
33
|
time.strftime('%b %d, %Y %H:%M:%S')
|
|
24
34
|
end
|
|
25
35
|
|
|
36
|
+
def event_timestamp(event)
|
|
37
|
+
event.timestamp || event.created_at
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def format_event_options(options)
|
|
41
|
+
return nil if options.blank?
|
|
42
|
+
|
|
43
|
+
JSON.pretty_generate(options)
|
|
44
|
+
rescue JSON::GeneratorError
|
|
45
|
+
options.to_s
|
|
46
|
+
end
|
|
47
|
+
|
|
26
48
|
def event_type_badge_class(event_type)
|
|
27
49
|
case event_type.to_s.downcase
|
|
28
50
|
when 'delivery', 'send' then 'badge-delivered'
|