notificare 0.1.0.alpha.1

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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +899 -0
  4. data/app/assets/stylesheets/active_job/notificare/engine.css +425 -0
  5. data/app/controllers/active_job/notificare/application_controller.rb +7 -0
  6. data/app/controllers/active_job/notificare/executions_controller.rb +41 -0
  7. data/app/controllers/active_job/notificare/notifications_controller.rb +72 -0
  8. data/app/helpers/active_job/notificare/view_helpers.rb +43 -0
  9. data/app/models/active_job/notificare/application_record.rb +7 -0
  10. data/app/models/active_job/notificare/execution.rb +20 -0
  11. data/app/models/active_job/notificare/notification.rb +50 -0
  12. data/app/views/active_job/notificare/_notification.html.erb +19 -0
  13. data/app/views/active_job/notificare/_notifications.html.erb +7 -0
  14. data/app/views/active_job/notificare/_progress.html.erb +17 -0
  15. data/app/views/active_job/notificare/executions/index.html.erb +75 -0
  16. data/app/views/active_job/notificare/executions/show.html.erb +66 -0
  17. data/app/views/active_job/notificare/notifications/clear.turbo_stream.erb +3 -0
  18. data/app/views/active_job/notificare/notifications/dismiss.turbo_stream.erb +1 -0
  19. data/app/views/active_job/notificare/notifications/read.turbo_stream.erb +3 -0
  20. data/app/views/layouts/active_job/notificare/application.html.erb +42 -0
  21. data/config/locales/en.yml +7 -0
  22. data/config/routes.rb +13 -0
  23. data/lib/active_job/notificare/concern.rb +78 -0
  24. data/lib/active_job/notificare/engine.rb +28 -0
  25. data/lib/active_job/notificare/progress_handle.rb +23 -0
  26. data/lib/active_job/notificare/projection.rb +145 -0
  27. data/lib/active_job/notificare/recipient.rb +39 -0
  28. data/lib/active_job/notificare/step_dsl.rb +42 -0
  29. data/lib/active_job/notificare/version.rb +5 -0
  30. data/lib/active_job/notificare.rb +14 -0
  31. data/lib/generators/active_job/notificare/install/install_generator.rb +56 -0
  32. data/lib/generators/active_job/notificare/install/templates/_notification.html.erb.tt +19 -0
  33. data/lib/generators/active_job/notificare/install/templates/_notifications.html.erb.tt +7 -0
  34. data/lib/generators/active_job/notificare/install/templates/_progress.html.erb.tt +17 -0
  35. data/lib/generators/active_job/notificare/install/templates/create_active_job_notificare_tables.rb.tt +36 -0
  36. data/lib/generators/active_job/notificare/install/templates/initializer.rb.tt +24 -0
  37. data/lib/generators/active_job/notificare/scaffold/scaffold_generator.rb +74 -0
  38. data/lib/generators/active_job/notificare/scaffold/templates/controller.rb.tt +31 -0
  39. data/lib/generators/active_job/notificare/scaffold/templates/index.html.erb.tt +26 -0
  40. data/lib/generators/active_job/notificare/scaffold/templates/locale.en.yml.tt +18 -0
  41. data/lib/generators/active_job/notificare/scaffold/templates/show.html.erb.tt +39 -0
  42. data/lib/notificare.rb +4 -0
  43. metadata +118 -0
@@ -0,0 +1,7 @@
1
+ <%= turbo_stream_from "active_job_notifications", recipient.to_gid_param %>
2
+ <div id="active_job_notifications" class="notificare-inbox">
3
+ <%= button_to t(".clear_all"), notificare.clear_notifications_path, method: :delete %>
4
+ <% notifications.each do |notification| %>
5
+ <%= render "active_job/notificare/notification", notification: notification %>
6
+ <% end %>
7
+ </div>
@@ -0,0 +1,17 @@
1
+ <%= turbo_stream_from "active_job_progress", execution.job_id %>
2
+ <div id="<%= dom_id(execution) %>" class="notificare-progress">
3
+ <% if execution.progress_total.present? %>
4
+ <progress class="notificare-progress__bar" value="<%= execution.progress_current %>" max="<%= execution.progress_total %>"></progress>
5
+ <span class="notificare-progress__label"><%= execution.progress_current %>/<%= execution.progress_total %> (<%= (execution.progress_current.to_f / execution.progress_total * 100).round %>%)</span>
6
+ <% if execution.current_step.present? %>
7
+ <span class="notificare-progress__step"><%= execution.current_step %></span>
8
+ <% end %>
9
+ <% else %>
10
+ <% if !execution.failed? %>
11
+ <span class="notificare-progress__spinner" role="status" aria-label="Loading..."></span>
12
+ <% end %>
13
+ <% if execution.current_step.present? %>
14
+ <span class="notificare-progress__step"><%= execution.current_step %></span>
15
+ <% end %>
16
+ <% end %>
17
+ </div>
@@ -0,0 +1,75 @@
1
+ <h1>Job Executions</h1>
2
+ <p class="nf-subtitle">Live registry of background job activity</p>
3
+
4
+ <%= form_with url: executions_path, method: :get, local: true, class: "nf-filters" do |f| %>
5
+ <%= f.select :status,
6
+ [ [ "All statuses", "" ] ] + @statuses.map { |s| [ s.titleize, s ] },
7
+ { selected: params[:status] } %>
8
+ <%= f.select :job_class,
9
+ [ [ "All job classes", "" ] ] + @job_classes.map { |c| [ c, c ] },
10
+ { selected: params[:job_class] } %>
11
+ <%= f.submit "Filter" %>
12
+ <% if params[:status].present? || params[:job_class].present? %>
13
+ <%= link_to "Clear", executions_path, class: "nf-clear" %>
14
+ <% end %>
15
+ <% end %>
16
+
17
+ <div class="nf-card">
18
+ <% if @executions.any? %>
19
+ <table class="nf-table">
20
+ <thead>
21
+ <tr>
22
+ <th>Status</th>
23
+ <th>Job class</th>
24
+ <th>Job ID</th>
25
+ <th>Step</th>
26
+ <th>Progress</th>
27
+ <th>Started</th>
28
+ <th>Finished</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @executions.each do |execution| %>
33
+ <tr>
34
+ <td><span class="nf-badge nf-badge--<%= execution.status %>"><%= execution.status %></span></td>
35
+ <td><%= link_to execution.job_class, execution_path(execution) %></td>
36
+ <td class="nf-mono"><%= truncate(execution.job_id, length: 22) %></td>
37
+ <td><%= execution.current_step %></td>
38
+ <td>
39
+ <% if execution.progress_total %>
40
+ <span class="nf-mono"><%= execution.progress_current %> / <%= execution.progress_total %></span>
41
+ <% end %>
42
+ </td>
43
+ <td class="nf-mono"><%= execution.started_at&.strftime("%Y-%m-%d %H:%M:%S") %></td>
44
+ <td class="nf-mono"><%= execution.completed_at&.strftime("%Y-%m-%d %H:%M:%S") %></td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+ <% else %>
50
+ <div class="nf-empty">No executions found.</div>
51
+ <% end %>
52
+ </div>
53
+
54
+ <% if @total_pages > 1 %>
55
+ <nav class="nf-pagination" aria-label="Pagination">
56
+ <% if @page > 1 %>
57
+ <%= link_to "← Prev", executions_path(page: @page - 1, status: params[:status], job_class: params[:job_class]) %>
58
+ <% end %>
59
+
60
+ <% window_start = [ 1, @page - 2 ].max %>
61
+ <% window_end = [ @total_pages, @page + 2 ].min %>
62
+ <% (window_start..window_end).each do |p| %>
63
+ <% if p == @page %>
64
+ <span class="nf-pagination__current"><%= p %></span>
65
+ <% else %>
66
+ <%= link_to p, executions_path(page: p, status: params[:status], job_class: params[:job_class]) %>
67
+ <% end %>
68
+ <% end %>
69
+
70
+ <% if @page < @total_pages %>
71
+ <%= link_to "Next →", executions_path(page: @page + 1, status: params[:status], job_class: params[:job_class]) %>
72
+ <% end %>
73
+ </nav>
74
+ <p class="nf-pagination__info"><%= @total_count %> total</p>
75
+ <% end %>
@@ -0,0 +1,66 @@
1
+ <%= link_to "← executions", executions_path, class: "nf-back" %>
2
+
3
+ <h1><%= @execution.job_class %></h1>
4
+ <p class="nf-subtitle nf-mono"><%= @execution.job_id %></p>
5
+
6
+ <div class="nf-detail">
7
+ <div>
8
+ <div class="nf-card">
9
+ <div class="nf-card__header">Execution details</div>
10
+ <div class="nf-card__body">
11
+ <dl class="nf-kv">
12
+ <dt>Status</dt>
13
+ <dd><%= tag.span class: "nf-badge nf-badge--#{@execution.status}" do %><%= @execution.status %><% end %></dd>
14
+
15
+ <dt>Current step</dt>
16
+ <dd><%= @execution.current_step.presence || "—" %></dd>
17
+
18
+ <dt>Started at</dt>
19
+ <dd><%= @execution.started_at&.strftime("%Y-%m-%d %H:%M:%S UTC") || "—" %></dd>
20
+
21
+ <dt>Completed at</dt>
22
+ <dd><%= @execution.completed_at&.strftime("%Y-%m-%d %H:%M:%S UTC") || "—" %></dd>
23
+ </dl>
24
+
25
+ <% if @execution.error.present? %>
26
+ <div class="nf-error"><%= @execution.error %></div>
27
+ <% end %>
28
+ </div>
29
+ </div>
30
+ </div>
31
+
32
+ <div>
33
+ <div class="nf-card">
34
+ <div class="nf-card__header">Live progress</div>
35
+ <div class="nf-card__body">
36
+ <%= active_job_notificare(@execution) %>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <% if @notifications.any? %>
43
+ <div class="nf-card">
44
+ <div class="nf-card__header">Notifications (<%= @notifications.size %>)</div>
45
+ <table class="nf-table">
46
+ <thead>
47
+ <tr>
48
+ <th>Type</th>
49
+ <th>Title</th>
50
+ <th>Description</th>
51
+ <th>Time</th>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ <% @notifications.each do |notification| %>
56
+ <tr>
57
+ <td><%= tag.span class: "nf-badge nf-badge--#{notification.event_type}" do %><%= notification.event_type %><% end %></td>
58
+ <td><%= notification.title %></td>
59
+ <td><%= notification.description %></td>
60
+ <td class="nf-mono"><%= notification.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
61
+ </tr>
62
+ <% end %>
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% @notifications.each do |notification| %>
2
+ <%= turbo_stream.remove dom_id(notification) %>
3
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= turbo_stream.remove dom_id(@notification) %>
@@ -0,0 +1,3 @@
1
+ <%= turbo_stream.replace dom_id(@notification) do %>
2
+ <%= render "active_job/notificare/notification", notification: @notification %>
3
+ <% end %>
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Notificare</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <script nonce="<%= content_security_policy_nonce %>">
8
+ (function(){
9
+ var t=localStorage.getItem('nf-theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
10
+ document.documentElement.dataset.theme=t;
11
+ })();
12
+ </script>
13
+ <%= stylesheet_link_tag "active_job/notificare/engine" %>
14
+ <%= javascript_importmap_tags if respond_to?(:javascript_importmap_tags) %>
15
+ </head>
16
+ <body>
17
+ <header class="nf-header">
18
+ <a href="<%= executions_path %>" class="nf-brand">
19
+ <span class="nf-brand__mark" aria-hidden="true">✦</span>
20
+ Notificare
21
+ </a>
22
+ <button class="nf-theme-toggle" id="nf-theme-toggle" aria-label="Toggle colour theme"></button>
23
+ </header>
24
+ <div class="nf-folk-strip" aria-hidden="true"></div>
25
+ <main class="nf-main">
26
+ <%= yield %>
27
+ </main>
28
+ <script nonce="<%= content_security_policy_nonce %>">
29
+ (function(){
30
+ var btn=document.getElementById('nf-theme-toggle');
31
+ function sync(t){ btn.textContent=t==='dark'?'☀':'☾'; btn.title=t==='dark'?'Switch to light':'Switch to dark'; }
32
+ sync(document.documentElement.dataset.theme);
33
+ btn.addEventListener('click',function(){
34
+ var next=document.documentElement.dataset.theme==='dark'?'light':'dark';
35
+ document.documentElement.dataset.theme=next;
36
+ localStorage.setItem('nf-theme',next);
37
+ sync(next);
38
+ });
39
+ })();
40
+ </script>
41
+ </body>
42
+ </html>
@@ -0,0 +1,7 @@
1
+ en:
2
+ active_job:
3
+ notificare:
4
+ notifications:
5
+ clear_all: "Clear all"
6
+ mark_as_read: "Mark as read"
7
+ dismiss: "Dismiss"
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ ActiveJob::Notificare::Engine.routes.draw do
2
+ root to: "executions#index"
3
+ resources :executions, only: [ :index, :show ]
4
+
5
+ resources :notifications, only: [] do
6
+ member do
7
+ patch :read
8
+ patch :dismiss
9
+ end
10
+ end
11
+
12
+ delete "notifications", to: "notifications#clear", as: :clear_notifications
13
+ end
@@ -0,0 +1,78 @@
1
+ require "active_support/concern"
2
+ require "active_job/continuation"
3
+ require "active_job/continuable"
4
+
5
+ module ActiveJob
6
+ module Notificare
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include ActiveJob::Continuable
11
+ include StepDSL
12
+ include Recipient
13
+ end
14
+
15
+ class_methods do
16
+ # Opt out of the projection. Including the module is the opt-in;
17
+ # `tracks_progress false` flips it off without removing the include.
18
+ def tracks_progress(value = true)
19
+ @tracks_progress = value
20
+ end
21
+
22
+ def tracks_progress?
23
+ @tracks_progress != false
24
+ end
25
+
26
+ # Declare which lifecycle events auto-write a Notification row.
27
+ # Accepted values: :completed, :failed (or both).
28
+ def notify_on(*event_types)
29
+ @_notificare_notify_on = event_types.map(&:to_sym)
30
+ end
31
+
32
+ def notificare_notify_on
33
+ @_notificare_notify_on || []
34
+ end
35
+
36
+ # Eagerly opt into recipient enforcement at enqueue time, before any instance
37
+ # has called notify(...). Use this when you know the job will call notify but
38
+ # want the ArgumentError to fire on the very first enqueue.
39
+ def uses_notify!
40
+ @_uses_notify = true
41
+ end
42
+
43
+ # True if uses_notify! was called explicitly, or after the first instance called
44
+ # notify(...) during perform (which flips this flag).
45
+ def uses_notify?
46
+ @_uses_notify == true
47
+ end
48
+ end
49
+
50
+ # The polymorphic recipient for notifications. Job authors set this inside
51
+ # perform (e.g. `self.recipient = recipient`).
52
+ attr_accessor :recipient
53
+
54
+ def progress
55
+ @progress ||= ProgressHandle.new(job_id)
56
+ end
57
+
58
+ # Write a custom Notification row directly. Safe to call at any point during or
59
+ # after perform — does not rely on lifecycle hooks (ERD §9 case 5).
60
+ #
61
+ # Also flips self.class.uses_notify? to true so subsequent enqueues are subject
62
+ # to recipient enforcement.
63
+ def notify(title:, description: nil, metadata: {}, actions: [])
64
+ self.class.uses_notify!
65
+ return unless recipient
66
+
67
+ Notification.create!(
68
+ recipient: recipient,
69
+ job_id: job_id,
70
+ event_type: "custom",
71
+ title: title,
72
+ description: description,
73
+ metadata: metadata.presence,
74
+ actions: actions.presence
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,28 @@
1
+ require "active_job/notificare/projection"
2
+
3
+ module ActiveJob
4
+ module Notificare
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace ActiveJob::Notificare
7
+
8
+ initializer "active_job.notificare.projection" do
9
+ ActiveJob::Notificare::Projection.subscribe!
10
+ end
11
+
12
+ initializer "active_job.notificare.helpers" do
13
+ ActiveSupport.on_load(:action_view) do
14
+ include ActiveJob::Notificare::ViewHelpers
15
+ end
16
+ end
17
+
18
+ config.to_prepare do
19
+ unless ::Notificare.const_defined?(:Execution, false)
20
+ ::Notificare.const_set(:Execution, ActiveJob::Notificare::Execution)
21
+ end
22
+ unless ::Notificare.const_defined?(:Notification, false)
23
+ ::Notificare.const_set(:Notification, ActiveJob::Notificare::Notification)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class ProgressHandle
4
+ def initialize(job_id)
5
+ @job_id = job_id
6
+ end
7
+
8
+ def total(n)
9
+ rows = Execution.where(job_id: @job_id).update_all(progress_total: n)
10
+ if rows == 0
11
+ Rails.logger.debug { "[notificare] progress.total called before execution row exists for job_id=#{@job_id}" }
12
+ end
13
+ end
14
+
15
+ def advance!(by = 1)
16
+ rows = Execution.where(job_id: @job_id).update_all("progress_current = progress_current + #{by.to_i}")
17
+ if rows == 0
18
+ Rails.logger.debug { "[notificare] progress.advance! called before execution row exists for job_id=#{@job_id}" }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,145 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ module Projection
4
+ SUBSCRIPTIONS = []
5
+
6
+ def self.subscribe!
7
+ SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("enqueue.active_job") do |event|
8
+ job = event.payload[:job]
9
+ next unless tracks_progress?(job)
10
+
11
+ begin
12
+ Execution.find_or_create_by!(job_id: job.job_id) do |e|
13
+ e.job_class = job.class.name
14
+ e.status = "enqueued"
15
+ end
16
+ rescue ActiveRecord::RecordNotUnique
17
+ # Two concurrent projections for the same job_id — uniqueness constraint caught the
18
+ # duplicate; the other thread won, so just find the existing row.
19
+ Execution.find_by!(job_id: job.job_id)
20
+ end
21
+ end
22
+
23
+ SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("perform_start.active_job") do |event|
24
+ job = event.payload[:job]
25
+ next unless tracks_progress?(job)
26
+
27
+ execution = Execution.find_by(job_id: job.job_id)
28
+ next unless execution
29
+
30
+ if execution.running?
31
+ # Resume path (ERD §9 case 3): worker was killed before perform.active_job fired,
32
+ # leaving status as running. Preserve progress_current and started_at.
33
+ # No continuation_state column — Continuation owns that (ERD §6).
34
+ execution.update!(error: nil) if execution.error.present?
35
+ else
36
+ execution.update!(status: "running", started_at: Time.current)
37
+ end
38
+ end
39
+
40
+ # Mirror ActiveJob::Continuation's current step name onto the execution row.
41
+ SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("step_started.active_job") do |event|
42
+ job = event.payload[:job]
43
+ next unless tracks_progress?(job)
44
+
45
+ step = event.payload[:step]
46
+ Execution.find_by(job_id: job.job_id)&.update!(current_step: step.name.to_s)
47
+ end
48
+
49
+ # ActiveJob::Continuation fires `step.active_job` (not `step_completed`) after each step's
50
+ # block finishes (whether successfully or with an exception). Only write a notification
51
+ # when the step completed without error and was not interrupted.
52
+ SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("step.active_job") do |event|
53
+ next if event.payload[:exception_object]
54
+ next if event.payload[:interrupted]
55
+
56
+ job = event.payload[:job]
57
+ next unless tracks_progress?(job)
58
+
59
+ step = event.payload[:step]
60
+ notify_event = job.respond_to?(:notificare_step_notify_for) ? job.notificare_step_notify_for(step.name) : nil
61
+ write_step_notification(job, step.name, notify_event) if notify_event
62
+ end
63
+
64
+ SUBSCRIPTIONS << ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
65
+ job = event.payload[:job]
66
+ next unless tracks_progress?(job)
67
+
68
+ execution = Execution.find_by(job_id: job.job_id)
69
+ next unless execution
70
+
71
+ if (exception = event.payload[:exception_object])
72
+ execution.update!(status: "failed", completed_at: Time.current, error: exception.message)
73
+ write_lifecycle_notification(job, :failed, exception.message) if notifies_on?(job, :failed)
74
+ else
75
+ execution.update!(status: "completed", completed_at: Time.current)
76
+ write_lifecycle_notification(job, :completed) if notifies_on?(job, :completed)
77
+ end
78
+ end
79
+ end
80
+
81
+ def self.unsubscribe!
82
+ SUBSCRIPTIONS.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
83
+ SUBSCRIPTIONS.clear
84
+ end
85
+
86
+ def self.tracks_progress?(job)
87
+ job.class.respond_to?(:tracks_progress?) && job.class.tracks_progress?
88
+ end
89
+ private_class_method :tracks_progress?
90
+
91
+ def self.notifies_on?(job, event_type)
92
+ job.class.respond_to?(:notificare_notify_on) && job.class.notificare_notify_on.include?(event_type)
93
+ end
94
+ private_class_method :notifies_on?
95
+
96
+ def self.recipient_for(job)
97
+ job.respond_to?(:recipient) ? job.recipient : nil
98
+ end
99
+ private_class_method :recipient_for
100
+
101
+ def self.write_lifecycle_notification(job, event_type, description = nil)
102
+ recipient = recipient_for(job)
103
+ return unless recipient
104
+
105
+ Notification.create!(
106
+ recipient: recipient,
107
+ job_id: job.job_id,
108
+ event_type: event_type.to_s,
109
+ title: "#{job.class.name} #{event_type}",
110
+ description: description
111
+ )
112
+ end
113
+ private_class_method :write_lifecycle_notification
114
+
115
+ def self.write_step_notification(job, step_name, notify_event)
116
+ recipient = recipient_for(job)
117
+ return unless recipient
118
+
119
+ Notification.create!(build_step_notification_attrs(job, step_name, notify_event))
120
+ end
121
+ private_class_method :write_step_notification
122
+
123
+ def self.build_step_notification_attrs(job, step_name, notify_event)
124
+ base = { recipient: recipient_for(job), job_id: job.job_id, event_type: "custom" }
125
+
126
+ case notify_event
127
+ when Symbol
128
+ base.merge(
129
+ title: "#{job.class.name}: #{notify_event}",
130
+ metadata: { "event" => notify_event.to_s }
131
+ )
132
+ when Hash
133
+ event = notify_event[:event] || step_name
134
+ extra_metadata = notify_event[:metadata]&.transform_keys(&:to_s) || {}
135
+ base.merge(
136
+ title: notify_event[:title] || "#{job.class.name}: #{event}",
137
+ description: notify_event[:description],
138
+ metadata: { "event" => event.to_s }.merge(extra_metadata)
139
+ )
140
+ end
141
+ end
142
+ private_class_method :build_step_notification_attrs
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,39 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveJob
4
+ module Notificare
5
+ # around_enqueue guard: raises ArgumentError before the adapter receives the job
6
+ # when the job opts into notifications but no `recipient:` keyword was supplied.
7
+ #
8
+ # Opt-in triggers (any one is sufficient):
9
+ # - notify_on declared on the class
10
+ # - uses_notify! called on the class (or uses_notify? already true from a prior run)
11
+ # - step(notify:) was called in a prior run (has_step_notifications? is true)
12
+ module Recipient
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ around_enqueue :enforce_recipient!
17
+ end
18
+
19
+ private
20
+
21
+ def enforce_recipient!
22
+ if needs_recipient? && !recipient_argument_present?
23
+ raise ArgumentError, "#{self.class.name} requires a `recipient:` keyword argument"
24
+ end
25
+ yield
26
+ end
27
+
28
+ def needs_recipient?
29
+ self.class.notificare_notify_on.any? ||
30
+ self.class.uses_notify? ||
31
+ self.class.has_step_notifications?
32
+ end
33
+
34
+ def recipient_argument_present?
35
+ arguments.any? { |arg| arg.is_a?(Hash) && (arg.key?(:recipient) || arg.key?("recipient")) }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ require "active_support/concern"
2
+
3
+ module ActiveJob
4
+ module Notificare
5
+ # Wraps ActiveJob::Continuation#step with Notificare-specific kwargs.
6
+ #
7
+ # `notify:` declares a state-machine event tied to the step's successful completion.
8
+ # The value is stashed on the job instance keyed by step name; the projection reads it
9
+ # off `event.payload[:job]` at `step_completed.active_job` time. Actual Notification
10
+ # row writes land in ticket 06.
11
+ module StepDSL
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ extend ClassMethods
16
+ end
17
+
18
+ module ClassMethods
19
+ # True after the first run of any instance that called step(notify:).
20
+ # Used by Recipient to enforce recipient: presence on subsequent enqueues.
21
+ def has_step_notifications?
22
+ @_has_step_notifications == true
23
+ end
24
+ end
25
+
26
+ def step(name, *args, notify: nil, **opts, &block)
27
+ if notify
28
+ self.class.instance_variable_set(:@_has_step_notifications, true)
29
+ @_notificare_step_notify ||= {}
30
+ @_notificare_step_notify[name.to_sym] = notify
31
+ end
32
+ super(name, *args, **opts, &block)
33
+ end
34
+
35
+ # Read by Projection's `step_completed.active_job` handler.
36
+ def notificare_step_notify_for(step_name)
37
+ return nil unless defined?(@_notificare_step_notify) && @_notificare_step_notify
38
+ @_notificare_step_notify[step_name.to_sym]
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ VERSION = "0.1.0.alpha.1"
4
+ end
5
+ end
@@ -0,0 +1,14 @@
1
+ require "active_job/notificare/version"
2
+ require "active_job/notificare/engine"
3
+ require "active_job/notificare/progress_handle"
4
+ require "active_job/notificare/step_dsl"
5
+ require "active_job/notificare/recipient"
6
+ require "active_job/notificare/concern"
7
+
8
+ module ActiveJob
9
+ module Notificare
10
+ mattr_accessor :current_recipient_proc
11
+ mattr_accessor :parent_controller, default: "ApplicationController"
12
+ mattr_accessor :authenticate_with
13
+ end
14
+ end