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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/app/assets/config/sincerely.js +2 -0
  4. data/app/assets/images/sincerely/arrow_left.svg +1 -0
  5. data/app/assets/images/sincerely/check.svg +3 -0
  6. data/app/assets/images/sincerely/chevron_left.svg +1 -0
  7. data/app/assets/images/sincerely/clock.svg +1 -0
  8. data/app/assets/images/sincerely/code.svg +1 -0
  9. data/app/assets/images/sincerely/eye.svg +1 -0
  10. data/app/assets/images/sincerely/logout.svg +1 -0
  11. data/app/assets/images/sincerely/menu.svg +1 -0
  12. data/app/assets/images/sincerely/moon.svg +1 -0
  13. data/app/assets/images/sincerely/pencil.svg +4 -0
  14. data/app/assets/images/sincerely/plus.svg +3 -0
  15. data/app/assets/images/sincerely/progress_spinner.svg +3 -0
  16. data/app/assets/images/sincerely/spinner.svg +3 -0
  17. data/app/assets/images/sincerely/sun.svg +1 -0
  18. data/app/assets/images/sincerely/warning_circle.svg +4 -0
  19. data/app/assets/javascripts/sincerely/charts.js +115 -0
  20. data/app/assets/javascripts/sincerely/clickable_rows.js +29 -0
  21. data/app/assets/javascripts/sincerely/layout.js +104 -0
  22. data/app/assets/javascripts/sincerely/send_form.js +243 -0
  23. data/app/assets/javascripts/sincerely/template_form.js +44 -0
  24. data/app/assets/javascripts/sincerely/template_preview.js +12 -0
  25. data/app/assets/javascripts/sincerely/theme_init.js +9 -0
  26. data/app/assets/stylesheets/sincerely/charts.css +250 -0
  27. data/app/assets/stylesheets/sincerely/components.css +632 -0
  28. data/app/assets/stylesheets/sincerely/layout.css +233 -0
  29. data/app/assets/stylesheets/sincerely/navigation.css +198 -0
  30. data/app/assets/stylesheets/sincerely/send_form.css +287 -0
  31. data/app/assets/stylesheets/sincerely/template_form.css +74 -0
  32. data/app/assets/stylesheets/sincerely/template_preview.css +75 -0
  33. data/app/assets/stylesheets/sincerely/variables.css +88 -0
  34. data/app/controllers/sincerely/application_controller.rb +3 -72
  35. data/app/controllers/sincerely/concerns/authorization.rb +21 -0
  36. data/app/controllers/sincerely/concerns/delivery_metrics.rb +51 -0
  37. data/app/controllers/sincerely/concerns/event_filtering.rb +58 -0
  38. data/app/controllers/sincerely/concerns/event_timeline.rb +41 -0
  39. data/app/controllers/sincerely/concerns/notification_timeline.rb +73 -0
  40. data/app/controllers/sincerely/concerns/notifications.rb +47 -0
  41. data/app/controllers/sincerely/concerns/pagination.rb +39 -0
  42. data/app/controllers/sincerely/concerns/periods.rb +40 -0
  43. data/app/controllers/sincerely/dashboard_controller.rb +30 -88
  44. data/app/controllers/sincerely/delivery_events_controller.rb +13 -21
  45. data/app/controllers/sincerely/engagement_events_controller.rb +13 -22
  46. data/app/controllers/sincerely/notifications_controller.rb +68 -49
  47. data/app/controllers/sincerely/send_controller.rb +82 -67
  48. data/app/controllers/sincerely/templates_controller.rb +37 -8
  49. data/app/helpers/sincerely/application_helper.rb +22 -0
  50. data/app/helpers/sincerely/chart_helper.rb +79 -0
  51. data/app/views/layouts/sincerely/application.html.erb +24 -528
  52. data/app/views/sincerely/dashboard/index.html.erb +25 -256
  53. data/app/views/sincerely/delivery_events/index.html.erb +6 -38
  54. data/app/views/sincerely/engagement_events/index.html.erb +6 -38
  55. data/app/views/sincerely/notifications/index.html.erb +7 -23
  56. data/app/views/sincerely/notifications/show.html.erb +4 -4
  57. data/app/views/sincerely/send/new.html.erb +8 -522
  58. data/app/views/sincerely/shared/_event.html.erb +21 -0
  59. data/app/views/sincerely/shared/_line_chart.html.erb +38 -0
  60. data/app/views/sincerely/shared/_notification_type_filter.html.erb +9 -0
  61. data/app/views/sincerely/shared/_status_option.html.erb +1 -0
  62. data/app/views/sincerely/shared/_template_option.html.erb +3 -0
  63. data/app/views/sincerely/templates/_form.html.erb +8 -107
  64. data/app/views/sincerely/templates/edit.html.erb +2 -2
  65. data/app/views/sincerely/templates/index.html.erb +7 -15
  66. data/app/views/sincerely/templates/new.html.erb +2 -2
  67. data/app/views/sincerely/templates/preview.html.erb +7 -26
  68. data/app/views/sincerely/templates/show.html.erb +3 -3
  69. data/lib/config/sincerely_config.rb +2 -2
  70. data/lib/sincerely/delivery_systems/email_aws_ses.rb +2 -2
  71. data/lib/sincerely/engine.rb +4 -0
  72. data/lib/sincerely/mixins/notification_model.rb +5 -1
  73. data/lib/sincerely/services/process_delivery_event.rb +9 -7
  74. data/lib/sincerely/templates/notification_template.rb +1 -1
  75. data/lib/sincerely/version.rb +1 -1
  76. metadata +52 -4
  77. 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