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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +899 -0
- data/app/assets/stylesheets/active_job/notificare/engine.css +425 -0
- data/app/controllers/active_job/notificare/application_controller.rb +7 -0
- data/app/controllers/active_job/notificare/executions_controller.rb +41 -0
- data/app/controllers/active_job/notificare/notifications_controller.rb +72 -0
- data/app/helpers/active_job/notificare/view_helpers.rb +43 -0
- data/app/models/active_job/notificare/application_record.rb +7 -0
- data/app/models/active_job/notificare/execution.rb +20 -0
- data/app/models/active_job/notificare/notification.rb +50 -0
- data/app/views/active_job/notificare/_notification.html.erb +19 -0
- data/app/views/active_job/notificare/_notifications.html.erb +7 -0
- data/app/views/active_job/notificare/_progress.html.erb +17 -0
- data/app/views/active_job/notificare/executions/index.html.erb +75 -0
- data/app/views/active_job/notificare/executions/show.html.erb +66 -0
- data/app/views/active_job/notificare/notifications/clear.turbo_stream.erb +3 -0
- data/app/views/active_job/notificare/notifications/dismiss.turbo_stream.erb +1 -0
- data/app/views/active_job/notificare/notifications/read.turbo_stream.erb +3 -0
- data/app/views/layouts/active_job/notificare/application.html.erb +42 -0
- data/config/locales/en.yml +7 -0
- data/config/routes.rb +13 -0
- data/lib/active_job/notificare/concern.rb +78 -0
- data/lib/active_job/notificare/engine.rb +28 -0
- data/lib/active_job/notificare/progress_handle.rb +23 -0
- data/lib/active_job/notificare/projection.rb +145 -0
- data/lib/active_job/notificare/recipient.rb +39 -0
- data/lib/active_job/notificare/step_dsl.rb +42 -0
- data/lib/active_job/notificare/version.rb +5 -0
- data/lib/active_job/notificare.rb +14 -0
- data/lib/generators/active_job/notificare/install/install_generator.rb +56 -0
- data/lib/generators/active_job/notificare/install/templates/_notification.html.erb.tt +19 -0
- data/lib/generators/active_job/notificare/install/templates/_notifications.html.erb.tt +7 -0
- data/lib/generators/active_job/notificare/install/templates/_progress.html.erb.tt +17 -0
- data/lib/generators/active_job/notificare/install/templates/create_active_job_notificare_tables.rb.tt +36 -0
- data/lib/generators/active_job/notificare/install/templates/initializer.rb.tt +24 -0
- data/lib/generators/active_job/notificare/scaffold/scaffold_generator.rb +74 -0
- data/lib/generators/active_job/notificare/scaffold/templates/controller.rb.tt +31 -0
- data/lib/generators/active_job/notificare/scaffold/templates/index.html.erb.tt +26 -0
- data/lib/generators/active_job/notificare/scaffold/templates/locale.en.yml.tt +18 -0
- data/lib/generators/active_job/notificare/scaffold/templates/show.html.erb.tt +39 -0
- data/lib/notificare.rb +4 -0
- metadata +118 -0
data/README.md
ADDED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
# Notificare
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/notificare)
|
|
4
|
+
[](https://www.ruby-lang.org/)
|
|
5
|
+
[](https://rubyonrails.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Notificare** (Romanian: *"to notify"*) is a Rails engine that adds **persisted progress tracking** and a **durable notification inbox** to your ActiveJob jobs — with a Hotwire UI scaffold included.
|
|
9
|
+
|
|
10
|
+
It is a projection layer over [`ActiveJob::Continuation`](https://api.rubyonrails.org/classes/ActiveJob/Continuation.html) (shipped in Rails 8.1). Continuation owns execution and step-resume state; Notificare owns the persisted projection of progress, the notification inbox, and the realtime UI for both. **Step boundaries become a state machine that drives notifications** — no manual broadcast plumbing.
|
|
11
|
+
|
|
12
|
+
Two concepts, intentionally separate:
|
|
13
|
+
|
|
14
|
+
- **Progress** (`active_job_executions`) — *transient* live state of a running job (status, current step, current/total).
|
|
15
|
+
- **Notifications** (`active_job_notifications`) — *durable* user-facing records of job events: completed, failed, custom per-step milestones.
|
|
16
|
+
|
|
17
|
+
Mental model: *Active Storage, but for job progress — plus a small inbox for what those jobs report back to the user.*
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Table of Contents
|
|
22
|
+
|
|
23
|
+
- [Features](#features)
|
|
24
|
+
- [Requirements](#requirements)
|
|
25
|
+
- [Installation](#installation)
|
|
26
|
+
- [Getting Started](#getting-started)
|
|
27
|
+
- [Job DSL](#job-dsl)
|
|
28
|
+
- [Progress tracking](#progress-tracking)
|
|
29
|
+
- [Step DSL with notifications](#step-dsl-with-notifications)
|
|
30
|
+
- [Lifecycle notifications (`notify_on`)](#lifecycle-notifications-notify_on)
|
|
31
|
+
- [Manual notifications (`notify(...)`)](#manual-notifications-notify)
|
|
32
|
+
- [Recipient Enforcement](#recipient-enforcement)
|
|
33
|
+
- [View Helpers](#view-helpers)
|
|
34
|
+
- [Hotwire / Turbo Streams](#hotwire--turbo-streams)
|
|
35
|
+
- [Notification Actions (Inbox)](#notification-actions-inbox)
|
|
36
|
+
- [Admin UI (Mounted Engine)](#admin-ui-mounted-engine)
|
|
37
|
+
- [Scaffold Generator](#scaffold-generator)
|
|
38
|
+
- [Configuration](#configuration)
|
|
39
|
+
- [Internationalization (I18n)](#internationalization-i18n)
|
|
40
|
+
- [Customizing the markup](#customizing-the-markup)
|
|
41
|
+
- [Styling](#styling)
|
|
42
|
+
- [Resume Semantics](#resume-semantics)
|
|
43
|
+
- [Adapter Compatibility](#adapter-compatibility)
|
|
44
|
+
- [Testing](#testing)
|
|
45
|
+
- [Playing with the gem locally](#playing-with-the-gem-locally)
|
|
46
|
+
- [Contributing](#contributing)
|
|
47
|
+
- [Releases](#releases)
|
|
48
|
+
- [License](#license)
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **One opt-in seam** — `include ActiveJob::Notificare` is everything you need; it auto-includes `ActiveJob::Continuable`.
|
|
55
|
+
- **Persisted progress** — every running job gets a row you can query, render, and update in realtime.
|
|
56
|
+
- **Step-driven notifications** — declare `step(:name, notify: :event)` and a notification row is written automatically on successful completion.
|
|
57
|
+
- **Lifecycle notifications** — `notify_on :completed, :failed` writes a notification when the job finishes (or fails).
|
|
58
|
+
- **Manual notifications** — call `notify(title:, description:, ...)` from anywhere inside `perform` for custom milestones.
|
|
59
|
+
- **Hotwire UI out of the box** — view helpers render progress bars and an inbox; updates broadcast over Turbo Streams.
|
|
60
|
+
- **Recipient enforcement** — jobs that produce notifications must be enqueued with `recipient:`; missing it raises before the adapter receives the job.
|
|
61
|
+
- **Resumable** — survives worker crashes via Continuation; the same execution row continues across resumes.
|
|
62
|
+
- **Adapter agnostic** — works with Solid Queue, GoodJob, Sidekiq, and any other ActiveJob adapter.
|
|
63
|
+
- **No monkey-patching** — everything hooks through `ActiveSupport::Notifications`.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
- **Ruby** ≥ 3.3
|
|
70
|
+
- **Rails** ≥ 8.1 (for `ActiveJob::Continuation`)
|
|
71
|
+
- **An ActiveJob queue adapter** (Solid Queue, GoodJob, Sidekiq, etc.). The default `:async` adapter is fine for development.
|
|
72
|
+
- **`turbo-rails`** (recommended). Without it, the model-level `Turbo::Broadcastable` hooks are skipped and the gem still works — but live progress, live inbox, and inline button responses (Mark as read, Dismiss, Clear all) all depend on Turbo. The view helpers degrade gracefully (no live updates), but inbox button submissions will navigate the browser unless Turbo is loaded on the page.
|
|
73
|
+
- **Turbo loaded in the browser.** Having `turbo-rails` in your Gemfile is not enough; your application layout must execute the Turbo runtime. In a default Rails 8 app this is automatic via importmap-rails (`<%= javascript_importmap_tags %>` in `app/views/layouts/application.html.erb` plus `import "@hotwired/turbo-rails"` in `app/javascript/application.js`). If you skipped JavaScript when generating the app, set this up before mounting the engine.
|
|
74
|
+
- **Action Cable** configured (it is by default in Rails 8). Required for `turbo_stream_from` subscriptions used by the progress and inbox helpers.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
### 1. Add the gem
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Gemfile
|
|
84
|
+
gem "notificare"
|
|
85
|
+
gem "turbo-rails" # recommended — enables live broadcasts and inline inbox actions
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
bundle install
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 2. Run the install generator
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bin/rails generate active_job:notificare:install
|
|
96
|
+
bin/rails db:migrate
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The generator creates:
|
|
100
|
+
|
|
101
|
+
| File | Purpose |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `db/migrate/<ts>_create_active_job_notificare_tables.rb` | Creates `active_job_executions` and `active_job_notifications` tables (plus indexes for `job_id`, `recipient`, and `read_at`). |
|
|
104
|
+
| `config/initializers/active_job_notificare.rb` | Empty initializer — uncomment knobs you want to override (see [Configuration](#configuration)). |
|
|
105
|
+
| `app/views/active_job/notificare/_progress.html.erb` | Progress widget partial. Owned by your app — customize freely. |
|
|
106
|
+
| `app/views/active_job/notificare/_notifications.html.erb` | Inbox wrapper partial (clear-all button + iteration). |
|
|
107
|
+
| `app/views/active_job/notificare/_notification.html.erb` | Single notification card partial (wrapped in `turbo_frame_tag`). |
|
|
108
|
+
|
|
109
|
+
The generator copies the partials into your app so the gem never ships markup that overrides yours. Edit them to match your design system; the underlying controllers, models, and Turbo Stream responses keep working as long as you keep the DOM ids and frame ids intact (see [Customizing the markup](#customizing-the-markup)).
|
|
110
|
+
|
|
111
|
+
### 3. Mount the engine
|
|
112
|
+
|
|
113
|
+
In `config/routes.rb`:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
mount ActiveJob::Notificare::Engine, at: "/notificare", as: :notificare
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The `as: :notificare` alias is **required** — it avoids a naming collision between the `active_job_notificare(execution)` view helper and the default route proxy. Internal partials reference `notificare.read_notification_path(...)`, `notificare.dismiss_notification_path(...)`, and `notificare.clear_notifications_path`, so the alias is part of the public contract.
|
|
120
|
+
|
|
121
|
+
The mount point itself (`/notificare`) is arbitrary — pick anything you like.
|
|
122
|
+
|
|
123
|
+
### 4. Make sure Turbo is loaded in the browser
|
|
124
|
+
|
|
125
|
+
If you generated your Rails app with `--skip-javascript`, the inbox actions will do full-page navigation instead of in-place updates. Verify your layout includes:
|
|
126
|
+
|
|
127
|
+
```erb
|
|
128
|
+
<%# app/views/layouts/application.html.erb %>
|
|
129
|
+
<%= javascript_importmap_tags %>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
…and your `app/javascript/application.js` imports Turbo:
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
import "@hotwired/turbo-rails"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
That's it. You're ready to wire up a job.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Getting Started
|
|
143
|
+
|
|
144
|
+
A complete example:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
class ImportJob < ApplicationJob
|
|
148
|
+
include ActiveJob::Notificare
|
|
149
|
+
|
|
150
|
+
notify_on :completed, :failed
|
|
151
|
+
|
|
152
|
+
def perform(import_id, recipient:)
|
|
153
|
+
self.recipient = recipient
|
|
154
|
+
@import = Import.find(import_id)
|
|
155
|
+
|
|
156
|
+
step(:validate, notify: :validated) do
|
|
157
|
+
@import.validate!
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
step(:import_rows) do |step|
|
|
161
|
+
progress.total(@import.rows.count)
|
|
162
|
+
@import.rows.find_each(start: step.cursor) do |row|
|
|
163
|
+
row.import
|
|
164
|
+
progress.advance!
|
|
165
|
+
step.advance! from: row.id
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
step :finalize
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def finalize
|
|
173
|
+
@import.finalize!
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Enqueue it with a `recipient:`:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
ImportJob.perform_later(import.id, recipient: current_user)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Render progress and the inbox in your views:
|
|
185
|
+
|
|
186
|
+
```erb
|
|
187
|
+
<%# In your "My Imports" page %>
|
|
188
|
+
<% @executions.each do |execution| %>
|
|
189
|
+
<%= active_job_notificare(execution) %>
|
|
190
|
+
<% end %>
|
|
191
|
+
|
|
192
|
+
<%# In your app shell / header %>
|
|
193
|
+
<%= active_job_notifications(for: current_user) %>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
That's it. Both helpers subscribe to Turbo Streams and update live as the job progresses and emits notifications.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Job DSL
|
|
201
|
+
|
|
202
|
+
`include ActiveJob::Notificare` is the **single seam**. Including it:
|
|
203
|
+
|
|
204
|
+
- pulls in `ActiveJob::Continuable` (so `step` works),
|
|
205
|
+
- adds the `progress` handle,
|
|
206
|
+
- registers the `notify_on` and `notify(...)` primitives,
|
|
207
|
+
- enables enqueue-time `recipient:` enforcement when notifications are in play,
|
|
208
|
+
- defaults `tracks_progress?` to `true` (use `tracks_progress false` to opt out).
|
|
209
|
+
|
|
210
|
+
### Progress tracking
|
|
211
|
+
|
|
212
|
+
Inside `perform`, use the `progress` handle:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
def perform
|
|
216
|
+
progress.total(items.count) # optional — omit for an indeterminate spinner
|
|
217
|
+
items.each do |item|
|
|
218
|
+
item.process!
|
|
219
|
+
progress.advance! # advance by 1
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
| Method | Description |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `progress.total(n)` | Declare expected work for the in-progress execution row (optional; omit for indeterminate). |
|
|
227
|
+
| `progress.advance!(by = 1)` | Increment `progress_current` on the execution row by `by` (defaults to 1). |
|
|
228
|
+
|
|
229
|
+
If `progress.total` is never called, the helper renders an indeterminate spinner. If `progress.total` is set, the helper renders a `<progress>` element with a `current/total` label and a percentage.
|
|
230
|
+
|
|
231
|
+
#### Opting out
|
|
232
|
+
|
|
233
|
+
`include ActiveJob::Notificare` defaults `tracks_progress?` to `true`. To opt a single job out of all projection writes (no execution row, no notifications) without removing the include — useful when you still want the DSL/notify helpers available conditionally — declare:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class QuietJob < ApplicationJob
|
|
237
|
+
include ActiveJob::Notificare
|
|
238
|
+
tracks_progress false
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Step DSL with notifications
|
|
243
|
+
|
|
244
|
+
`step(name, notify: ..., **continuation_opts, &block)` wraps Continuation's `step` and lets you fire a per-step notification on **successful completion**:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
# Symbol form — minimal
|
|
248
|
+
step(:validate, notify: :validated) do
|
|
249
|
+
@import.validate!
|
|
250
|
+
end
|
|
251
|
+
# → on success, writes a Notification with:
|
|
252
|
+
# event_type: "custom"
|
|
253
|
+
# metadata: { "event" => "validated" }
|
|
254
|
+
# title: "ImportJob: validated"
|
|
255
|
+
# description: nil
|
|
256
|
+
|
|
257
|
+
# Hash form — override anything
|
|
258
|
+
step(:charge, notify: { event: :charged, title: "Payment captured", description: "Card ending 4242", metadata: { amount_cents: 4999 } }) do
|
|
259
|
+
@order.charge!
|
|
260
|
+
end
|
|
261
|
+
# → on success, writes a Notification with:
|
|
262
|
+
# event_type: "custom"
|
|
263
|
+
# metadata: { "event" => "charged", "amount_cents" => 4999 }
|
|
264
|
+
# title: "Payment captured"
|
|
265
|
+
# description: "Card ending 4242"
|
|
266
|
+
|
|
267
|
+
# No notify: kwarg — just a Continuation step boundary
|
|
268
|
+
step :finalize
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
The block receives Continuation's `step` object. Use it for resumable cursors so a worker crash mid-step picks up where it left off:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
step(:import_rows) do |step|
|
|
275
|
+
progress.total(@import.rows.count)
|
|
276
|
+
@import.rows.find_each(start: step.cursor) do |row|
|
|
277
|
+
row.import
|
|
278
|
+
progress.advance!
|
|
279
|
+
step.advance! from: row.id # checkpoints the cursor for resume
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Failure semantics:** if the step raises (including `ActiveJob::Continuation::Interrupt`), no step-level notification is written. Lifecycle-level `failed` notifications still fire via `notify_on` if declared.
|
|
285
|
+
|
|
286
|
+
**Recipient enforcement:** declaring any `step(notify: ...)` flips the class into [recipient-required mode](#recipient-enforcement). Subsequent `perform_later` calls without a `recipient:` kwarg will raise `ArgumentError` before the job is enqueued.
|
|
287
|
+
|
|
288
|
+
### Lifecycle notifications (`notify_on`)
|
|
289
|
+
|
|
290
|
+
Declare which lifecycle events auto-write notification rows:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
class ExportJob < ApplicationJob
|
|
294
|
+
include ActiveJob::Notificare
|
|
295
|
+
notify_on :completed, :failed
|
|
296
|
+
|
|
297
|
+
def perform(report_id, recipient:)
|
|
298
|
+
self.recipient = recipient
|
|
299
|
+
# ...
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
When the job finishes, a notification row is written with:
|
|
305
|
+
|
|
306
|
+
- `event_type: "completed"` or `"failed"`
|
|
307
|
+
- `title: "<JobClass> <event_type>"` (e.g. `"ExportJob completed"`)
|
|
308
|
+
- `description`: the exception message for `failed`, otherwise nil
|
|
309
|
+
|
|
310
|
+
### Manual notifications (`notify(...)`)
|
|
311
|
+
|
|
312
|
+
Call `notify(...)` from anywhere inside `perform` to write a custom notification on demand:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
def perform(recipient:)
|
|
316
|
+
self.recipient = recipient
|
|
317
|
+
|
|
318
|
+
do_some_work
|
|
319
|
+
notify(
|
|
320
|
+
title: "Halfway there",
|
|
321
|
+
description: "Processed 500 of 1000 records",
|
|
322
|
+
metadata: { batch: 1 },
|
|
323
|
+
actions: [
|
|
324
|
+
{ label: "View progress", url: "/imports/123" },
|
|
325
|
+
{ label: "Cancel", url: "/imports/123/cancel" }
|
|
326
|
+
]
|
|
327
|
+
)
|
|
328
|
+
do_more_work
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Signature:** `notify(title:, description: nil, metadata: {}, actions: [])`
|
|
333
|
+
|
|
334
|
+
| Keyword | Required? | Description |
|
|
335
|
+
|---|---|---|
|
|
336
|
+
| `title` | yes | Plain text, rendered as `<strong>` in the notification card. |
|
|
337
|
+
| `description` | no | Plain text body, rendered as `<p>` only when present. |
|
|
338
|
+
| `metadata` | no | Free-form hash stored as JSON. Keys you write are preserved verbatim; useful for app-specific filtering. |
|
|
339
|
+
| `actions` | no | Array of `{ label:, url: }` hashes. Each one is rendered as an `<a>` inside the card's actions container. |
|
|
340
|
+
|
|
341
|
+
This writes a row with `event_type: "custom"` directly — independent of lifecycle hooks — so it is safe to call before, during, or after step boundaries, and any number of times per job. If `self.recipient` is nil at write time, the call is silently skipped (no exception, no row).
|
|
342
|
+
|
|
343
|
+
> **Heads-up:** the first `notify(...)` call flips the job class into "uses notifications" mode, so subsequent enqueues are subject to recipient enforcement. To opt in eagerly (so the very *first* enqueue raises if `recipient:` is missing), call `uses_notify!` at class definition:
|
|
344
|
+
>
|
|
345
|
+
> ```ruby
|
|
346
|
+
> class HalfwayPingJob < ApplicationJob
|
|
347
|
+
> include ActiveJob::Notificare
|
|
348
|
+
> uses_notify! # makes recipient: required from the very first perform_later
|
|
349
|
+
> end
|
|
350
|
+
> ```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Recipient Enforcement
|
|
355
|
+
|
|
356
|
+
Jobs that opt into notifications — via `notify_on`, any `step(notify:)`, or `uses_notify!` — **must** be enqueued with a `recipient:` keyword argument:
|
|
357
|
+
|
|
358
|
+
```ruby
|
|
359
|
+
ImportJob.perform_later(import.id, recipient: current_user) # ✅
|
|
360
|
+
ImportJob.perform_later(import.id) # ❌ raises ArgumentError
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The error is raised by an `around_enqueue` callback **before** the queue adapter receives the job. `recipient` accepts any object responding to `to_global_id` — typically an Active Record model.
|
|
364
|
+
|
|
365
|
+
Jobs that don't opt into notifications are unaffected; they can be enqueued with any signature.
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## View Helpers
|
|
370
|
+
|
|
371
|
+
The following helpers are auto-included into `ActionView::Base` by the engine:
|
|
372
|
+
|
|
373
|
+
### `active_job_notificare(execution)`
|
|
374
|
+
|
|
375
|
+
Renders a progress widget for a single `Execution`:
|
|
376
|
+
|
|
377
|
+
```erb
|
|
378
|
+
<%= active_job_notificare(execution) %>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
- **Determinate** (when `progress_total` is set): a `<progress>` element with a `current/total` label and a percentage.
|
|
382
|
+
- **Indeterminate** (when `progress_total` is nil): a CSS spinner.
|
|
383
|
+
- Shows `current_step` if present.
|
|
384
|
+
- Subscribes to `["active_job_progress", execution.job_id]`.
|
|
385
|
+
|
|
386
|
+
### `active_job_notifications(for: recipient)`
|
|
387
|
+
|
|
388
|
+
Renders the recipient's inbox of *visible* (not dismissed) notifications:
|
|
389
|
+
|
|
390
|
+
```erb
|
|
391
|
+
<%= active_job_notifications(for: current_user) %>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- Lists notifications newest-first.
|
|
395
|
+
- Unread items get a `notificare-notification--unread` modifier class.
|
|
396
|
+
- Each item has **Mark as read** and **Dismiss** buttons; each card is wrapped in a `<turbo-frame>` so actions update only that card inline — no page redirect.
|
|
397
|
+
- A **Clear all** action removes every visible notification for the recipient via Turbo Stream, with no redirect.
|
|
398
|
+
- Custom per-notification `actions:` are rendered as links.
|
|
399
|
+
- Subscribes to `["active_job_notifications", recipient.to_gid_param]`.
|
|
400
|
+
|
|
401
|
+
### Route path helpers
|
|
402
|
+
|
|
403
|
+
Three context-aware helpers are available for building custom views or scaffold-generated pages that need to link to engine notification actions without relying on the `notificare.` engine proxy:
|
|
404
|
+
|
|
405
|
+
| Helper | Resolves to |
|
|
406
|
+
|---|---|
|
|
407
|
+
| `notificare_read_notification_path(notification)` | `PATCH /notificare/notifications/:id/read` |
|
|
408
|
+
| `notificare_dismiss_notification_path(notification)` | `PATCH /notificare/notifications/:id/dismiss` |
|
|
409
|
+
| `notificare_clear_notifications_path` | `DELETE /notificare/notifications` |
|
|
410
|
+
|
|
411
|
+
In engine views these call the bare route helper directly; in host-app views they fall back to `url_for` with the full controller path, so they work regardless of how the engine is mounted. The installed partials (`_notifications.html.erb`, `_notification.html.erb`) still use `notificare.X` directly and require the `as: :notificare` mount alias.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## Hotwire / Turbo Streams
|
|
416
|
+
|
|
417
|
+
Both models include `Turbo::Broadcastable` (when `turbo-rails` is loaded) and call `broadcast_refresh_later_to` on every change. **You never need to write `broadcast_*` calls in user code.** A row create or update enqueues `Turbo::Streams::BroadcastStreamJob`, which sends a `<turbo-stream>` over Action Cable to every browser subscribed to that stream.
|
|
418
|
+
|
|
419
|
+
The two stable stream names are part of the public API — host apps can subscribe to them directly via `turbo_stream_from`:
|
|
420
|
+
|
|
421
|
+
| Surface | Stream identifier | When it broadcasts |
|
|
422
|
+
|---|---|---|
|
|
423
|
+
| Execution progress | `["active_job_progress", execution.job_id]` | After every `Execution` create/update (status changes, `progress_current` ticks, `current_step` mirrors). |
|
|
424
|
+
| Notifications inbox | `["active_job_notifications", recipient.to_gid_param]` | After every `Notification` create/update (new row, `mark_read!`, `dismiss!`). |
|
|
425
|
+
|
|
426
|
+
Stream names are rooted in the table-name domain (not the gem name), so future renames don't churn deployed Turbo subscriptions.
|
|
427
|
+
|
|
428
|
+
### What needs to be loaded
|
|
429
|
+
|
|
430
|
+
The view helpers emit `<turbo-cable-stream-source>` (via `turbo_stream_from`) and `<turbo-frame>` elements. For these to do anything in the browser:
|
|
431
|
+
|
|
432
|
+
1. **`turbo-rails`** must be in your Gemfile. Without it, the gem skips the broadcast hooks and no streams fire.
|
|
433
|
+
2. **Turbo must be imported** in your `app/javascript/application.js` (`import "@hotwired/turbo-rails"`) and the layout must execute it (`<%= javascript_importmap_tags %>`).
|
|
434
|
+
3. **Action Cable** must be running. In development, `bin/dev` (or `rails server`) handles this automatically when Turbo is loaded.
|
|
435
|
+
|
|
436
|
+
If any of those is missing, the helpers still render correctly on first load — you just won't see live updates, and the inbox buttons will navigate the browser instead of swapping in place.
|
|
437
|
+
|
|
438
|
+
### Subscribing manually
|
|
439
|
+
|
|
440
|
+
If you want the stream subscription without the gem's default markup (e.g. to render notifications inside your own component), subscribe directly:
|
|
441
|
+
|
|
442
|
+
```erb
|
|
443
|
+
<%= turbo_stream_from "active_job_notifications", current_user.to_gid_param %>
|
|
444
|
+
<div id="my_inbox">
|
|
445
|
+
<%# render however you like; updates arrive as turbo_stream broadcasts %>
|
|
446
|
+
</div>
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Notification Actions (Inbox)
|
|
452
|
+
|
|
453
|
+
The engine exposes three routes for inbox interactions, all scoped to the current recipient. Paths below are **relative to the engine mount point** (e.g. with `mount … at: "/notificare"`, the read path is `/notificare/notifications/:id/read`).
|
|
454
|
+
|
|
455
|
+
| Verb | Path | Helper | Action | Description |
|
|
456
|
+
|---|---|---|---|---|
|
|
457
|
+
| `PATCH` | `/notifications/:id/read` | `notificare.read_notification_path(id)` | `read` | Mark a single notification as read. |
|
|
458
|
+
| `PATCH` | `/notifications/:id/dismiss` | `notificare.dismiss_notification_path(id)` | `dismiss` | Dismiss (hide) a single notification. |
|
|
459
|
+
| `DELETE` | `/notifications` | `notificare.clear_notifications_path` | `clear` | Dismiss every visible notification for the current recipient. |
|
|
460
|
+
|
|
461
|
+
All three actions respond with Turbo Stream content — no redirect, no full-page reload:
|
|
462
|
+
|
|
463
|
+
| Action | Turbo Stream effect | Template |
|
|
464
|
+
|---|---|---|
|
|
465
|
+
| `read` | Replaces the notification's `<turbo-frame>` with the updated card (unread modifier removed, "Mark as read" button hidden) | `read.turbo_stream.erb` |
|
|
466
|
+
| `dismiss` | Removes the notification's `<turbo-frame>` from the DOM | `dismiss.turbo_stream.erb` |
|
|
467
|
+
| `clear` | Removes every visible notification frame in one response | `clear.turbo_stream.erb` |
|
|
468
|
+
|
|
469
|
+
> **Why does my "Mark as read" button navigate to `/notifications/1/read`?** That happens when Turbo isn't loaded in the browser. `button_to` submits a normal form, the browser follows the response, and the controller's HTML branch returns `head :ok` (a blank page at that URL). See [Requirements](#requirements) — `import "@hotwired/turbo-rails"` and `<%= javascript_importmap_tags %>` are both needed.
|
|
470
|
+
|
|
471
|
+
**Authorization:** the controller scopes `find` to `Notification.where(recipient: current_recipient)`. A request for someone else's notification returns **404**; an unresolved recipient returns **401**.
|
|
472
|
+
|
|
473
|
+
**CSRF:** the engine's `ApplicationController` calls `protect_from_forgery with: :exception`. `button_to` includes the CSRF token automatically; if you build a custom form, include `<%= csrf_meta_tags %>` in your layout.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## Admin UI (Mounted Engine)
|
|
478
|
+
|
|
479
|
+
The engine ships a minimal admin status page accessible at the engine's mount point (e.g. `/notificare`).
|
|
480
|
+
|
|
481
|
+
### Executions index
|
|
482
|
+
|
|
483
|
+
`GET /` (root) and `GET /executions` — paginated list of all executions. Filter by status or job class via query params:
|
|
484
|
+
|
|
485
|
+
```
|
|
486
|
+
/active_job_notificare/executions?status=failed
|
|
487
|
+
/active_job_notificare/executions?job_class=ImportJob
|
|
488
|
+
/active_job_notificare/executions?status=running&job_class=ExportJob
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Displays status badge, job class, job ID, current step, progress fraction, start/finish timestamps. Paginates at 25 rows per page.
|
|
492
|
+
|
|
493
|
+
### Execution show
|
|
494
|
+
|
|
495
|
+
`GET /executions/:id` — single execution detail with:
|
|
496
|
+
|
|
497
|
+
- Status, job ID, current step, started/completed timestamps, error message.
|
|
498
|
+
- **Live progress widget** — the same `active_job_notificare(execution)` helper used in your own views, subscribing to the Turbo Stream channel. Updates automatically without a page refresh while the job is running.
|
|
499
|
+
- **Tied notifications** — all `Notification` rows written for the same `job_id`, newest-first.
|
|
500
|
+
|
|
501
|
+
### Authentication
|
|
502
|
+
|
|
503
|
+
The admin UI is protected by `ActiveJob::Notificare.authenticate_with`. Configure it in an initializer:
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
# config/initializers/active_job_notificare.rb
|
|
507
|
+
ActiveJob::Notificare.authenticate_with = -> { current_user&.admin? }
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The lambda is evaluated via `instance_exec` inside the `ExecutionsController`, so it has full access to session state (params, cookies, `current_user`, etc.).
|
|
511
|
+
|
|
512
|
+
**Fail-safe default:** if `authenticate_with` is not configured and the environment is `production`, every request returns `403 Forbidden`. In development/test, unauthenticated access is allowed for convenience.
|
|
513
|
+
|
|
514
|
+
| Scenario | Result |
|
|
515
|
+
|---|---|
|
|
516
|
+
| `authenticate_with` not set + production | `403 Forbidden` |
|
|
517
|
+
| `authenticate_with` not set + non-production | allowed |
|
|
518
|
+
| `authenticate_with = -> { false }` | `403 Forbidden` |
|
|
519
|
+
| `authenticate_with = -> { true }` | allowed |
|
|
520
|
+
|
|
521
|
+
### Styling
|
|
522
|
+
|
|
523
|
+
The engine ships a small stylesheet (`active_job/notificare/engine.css`) included via the engine's own layout. The layout also loads `javascript_importmap_tags` if `importmap-rails` is present, enabling Turbo live updates on the show page. Host apps that use a different JS bundler should ensure Turbo is loaded on the page before visiting the admin UI.
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Scaffold Generator
|
|
528
|
+
|
|
529
|
+
For building your own product pages (e.g. "My Imports") that embed live progress and notifications, the scaffold generator creates a controller and views wired to Turbo Streams:
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
bin/rails generate active_job:notificare:scaffold ImportJob
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
For `ImportJob`, this creates:
|
|
536
|
+
|
|
537
|
+
| File | Purpose |
|
|
538
|
+
|---|---|
|
|
539
|
+
| `app/controllers/imports_controller.rb` | `#index` (executions scoped to the current recipient's notification history) and `#show` (detail + per-run notifications). |
|
|
540
|
+
| `app/views/imports/index.html.erb` | List of executions with live `active_job_notificare` progress widgets and the full notification inbox. All strings are I18n `t()` lookups. |
|
|
541
|
+
| `app/views/imports/show.html.erb` | Execution detail with live progress widget and per-run notification list, both subscribed via `turbo_stream_from`. All strings are I18n `t()` lookups. |
|
|
542
|
+
| `config/locales/active_job_notificare_imports.en.yml` | English translations for all view strings (titles, labels, headings, empty states). Override keys in your own locale files. |
|
|
543
|
+
|
|
544
|
+
A routes snippet is **printed to stdout** — the generator never modifies `config/routes.rb`. Paste it yourself:
|
|
545
|
+
|
|
546
|
+
```ruby
|
|
547
|
+
# config/routes.rb
|
|
548
|
+
resources :imports, only: [:index, :show]
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### Naming convention
|
|
552
|
+
|
|
553
|
+
`ImportJob` → `ImportsController`, `imports/` views, `imports_path`. The convention strips the `Job` suffix and pluralizes:
|
|
554
|
+
|
|
555
|
+
| Argument | Controller | Views directory | Locale file | Route helpers |
|
|
556
|
+
|---|---|---|---|---|
|
|
557
|
+
| `ImportJob` | `ImportsController` | `app/views/imports/` | `active_job_notificare_imports.en.yml` | `imports_path`, `import_path(id)` |
|
|
558
|
+
| `ReportExportJob` | `ReportExportsController` | `app/views/report_exports/` | `active_job_notificare_report_exports.en.yml` | `report_exports_path`, `report_export_path(id)` |
|
|
559
|
+
|
|
560
|
+
### Override flags
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
# Override just the controller class name
|
|
564
|
+
bin/rails generate active_job:notificare:scaffold ImportJob --controller=MyImportsController
|
|
565
|
+
|
|
566
|
+
# Override the route/view prefix
|
|
567
|
+
bin/rails generate active_job:notificare:scaffold ImportJob --prefix=my_imports
|
|
568
|
+
|
|
569
|
+
# Both flags are independent
|
|
570
|
+
bin/rails generate active_job:notificare:scaffold ImportJob \
|
|
571
|
+
--controller=MyImportsController --prefix=my_imports
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
### `current_recipient`
|
|
575
|
+
|
|
576
|
+
The generated controller exposes a `current_recipient` helper method (via `helper_method`) used by both actions and views to scope executions and notifications:
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
private
|
|
580
|
+
|
|
581
|
+
# TODO: replace with however your app exposes the signed-in user/account.
|
|
582
|
+
def current_recipient
|
|
583
|
+
current_notificare_recipient || current_user
|
|
584
|
+
end
|
|
585
|
+
helper_method :current_recipient
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Replace the body with whatever your app uses (`current_account`, `Current.user`, etc.).
|
|
589
|
+
|
|
590
|
+
### Validation
|
|
591
|
+
|
|
592
|
+
The generator validates that the named class exists and includes `ActiveJob::Notificare`. If the class is missing or doesn't include the concern, it prints an error and creates no files:
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
$ bin/rails generate active_job:notificare:scaffold String
|
|
596
|
+
error String does not include ActiveJob::Notificare.
|
|
597
|
+
Add `include ActiveJob::Notificare` to the job class and re-run the generator.
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Configuration
|
|
603
|
+
|
|
604
|
+
The gem exposes three module-level knobs, all `mattr_accessor` on `ActiveJob::Notificare`:
|
|
605
|
+
|
|
606
|
+
| Knob | Default | Purpose |
|
|
607
|
+
|---|---|---|
|
|
608
|
+
| `ActiveJob::Notificare.authenticate_with` | `nil` | Lambda evaluated via `instance_exec` in `ExecutionsController` to guard the admin UI. Nil in production denies access. |
|
|
609
|
+
| `ActiveJob::Notificare.current_recipient_proc` | `nil` | Lambda evaluated via `instance_exec` inside the engine's controllers to resolve the current recipient. Falls back to `current_notificare_recipient`, then `current_user`. |
|
|
610
|
+
| `ActiveJob::Notificare.parent_controller` | `"ApplicationController"` | The constant name (string) the engine's `ApplicationController` inherits from. Set this if your app routes everything through a custom base controller (e.g. `Api::BaseController`). |
|
|
611
|
+
|
|
612
|
+
Set them in `config/initializers/active_job_notificare.rb`.
|
|
613
|
+
|
|
614
|
+
### Resolving the current recipient
|
|
615
|
+
|
|
616
|
+
The notifications controller needs to know who the "current recipient" is for every request. The engine's `ApplicationController` inherits from `::ApplicationController` by default, so the simplest approach is to define `current_notificare_recipient` in your own `ApplicationController`:
|
|
617
|
+
|
|
618
|
+
```ruby
|
|
619
|
+
# app/controllers/application_controller.rb
|
|
620
|
+
def current_notificare_recipient
|
|
621
|
+
current_user # or however you expose the signed-in user
|
|
622
|
+
end
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The engine controller inherits this method and calls it automatically. If neither `current_notificare_recipient` nor `current_user` is defined, the engine raises `NotImplementedError` with a clear message pointing you here.
|
|
626
|
+
|
|
627
|
+
**Alternative — proc in an initializer:**
|
|
628
|
+
|
|
629
|
+
If you prefer not to touch your `ApplicationController`, set a proc instead. It is evaluated via `instance_exec` inside the engine's controller so it has full access to session state:
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
# config/initializers/active_job_notificare.rb
|
|
633
|
+
ActiveJob::Notificare.current_recipient_proc = -> { current_account }
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
**Advanced — custom parent controller:**
|
|
637
|
+
|
|
638
|
+
If your app uses a non-standard base controller (e.g. `Api::BaseController`), tell the engine to inherit from it instead of `ApplicationController`:
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
# config/initializers/active_job_notificare.rb
|
|
642
|
+
ActiveJob::Notificare.parent_controller = "Api::BaseController"
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
The engine's controllers will then inherit from that class, picking up any auth helpers it defines.
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## Internationalization (I18n)
|
|
650
|
+
|
|
651
|
+
All UI strings in the inbox partial use `t()` lookups. Override any key in your host app's locale files.
|
|
652
|
+
|
|
653
|
+
| Key | Default (en) |
|
|
654
|
+
|---|---|
|
|
655
|
+
| `active_job.notificare.notifications.clear_all` | `"Clear all"` |
|
|
656
|
+
| `active_job.notificare.notifications.mark_as_read` | `"Mark as read"` |
|
|
657
|
+
| `active_job.notificare.notifications.dismiss` | `"Dismiss"` |
|
|
658
|
+
|
|
659
|
+
```yaml
|
|
660
|
+
# config/locales/pt-BR.yml
|
|
661
|
+
pt-BR:
|
|
662
|
+
active_job:
|
|
663
|
+
notificare:
|
|
664
|
+
notifications:
|
|
665
|
+
clear_all: "Limpar tudo"
|
|
666
|
+
mark_as_read: "Marcar como lida"
|
|
667
|
+
dismiss: "Dispensar"
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## Customizing the markup
|
|
673
|
+
|
|
674
|
+
The install generator copies three partials into your app under `app/views/active_job/notificare/`. They are yours — edit them freely. The contracts the gem relies on are minimal:
|
|
675
|
+
|
|
676
|
+
| Partial | Required DOM hooks | Why |
|
|
677
|
+
|---|---|---|
|
|
678
|
+
| `_progress.html.erb` | `<%= turbo_stream_from "active_job_progress", execution.job_id %>` | Subscribes the widget to the execution's broadcast channel. |
|
|
679
|
+
| `_notifications.html.erb` | `<%= turbo_stream_from "active_job_notifications", recipient.to_gid_param %>`; outer wrapper `id="active_job_notifications"` | Subscribes the inbox; the wrapper id is the target Turbo refreshes broadcast to. |
|
|
680
|
+
| `_notification.html.erb` | `<%= turbo_frame_tag dom_id(notification) do %>…<% end %>` | The frame id (`active_job_notificare_notification_<id>`) is what the controller's `read.turbo_stream.erb` and `dismiss.turbo_stream.erb` target by id. |
|
|
681
|
+
|
|
682
|
+
Inside those constraints, structure the markup however you like — Tailwind classes, your own design system, ViewComponent wrappers, anything. The controllers, models, and Turbo Stream responses don't read your CSS classes.
|
|
683
|
+
|
|
684
|
+
If you need to render the inbox somewhere unusual (e.g. a sidebar that's only visible after a click), call the helper anyway — `<turbo-cable-stream-source>` works fine while hidden.
|
|
685
|
+
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
## Styling
|
|
689
|
+
|
|
690
|
+
The gem ships **no CSS** — it renders semantic markup with a stable `notificare-*` class hierarchy you can style however you like.
|
|
691
|
+
|
|
692
|
+
| Element | Class |
|
|
693
|
+
|---|---|
|
|
694
|
+
| Progress wrapper | `notificare-progress` |
|
|
695
|
+
| Determinate bar | `notificare-progress__bar` |
|
|
696
|
+
| Fraction/percentage label | `notificare-progress__label` |
|
|
697
|
+
| Current step name | `notificare-progress__step` |
|
|
698
|
+
| Indeterminate spinner | `notificare-progress__spinner` |
|
|
699
|
+
| Notifications inbox wrapper | `notificare-inbox` |
|
|
700
|
+
| Notification item | `notificare-notification` |
|
|
701
|
+
| Unread modifier | `notificare-notification--unread` |
|
|
702
|
+
| Notification title | `notificare-notification__title` |
|
|
703
|
+
| Notification description | `notificare-notification__description` |
|
|
704
|
+
| Notification actions container | `notificare-notification__actions` |
|
|
705
|
+
|
|
706
|
+
The inbox wrapper also has the DOM id `#active_job_notifications` for `turbo_stream` targeting.
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## Resume Semantics
|
|
711
|
+
|
|
712
|
+
When a worker is killed mid-step, `ActiveJob::Continuation` re-enqueues the job with the same `job_id`. The projection looks up the existing execution row by `job_id` and continues updating it:
|
|
713
|
+
|
|
714
|
+
- **No duplicate row** is created on resume (`find_or_create_by!` + `RecordNotUnique` rescue).
|
|
715
|
+
- **`progress_current` and `started_at` are preserved** across the resume.
|
|
716
|
+
- Any **stale `error` is cleared** when the job restarts.
|
|
717
|
+
- There is **no `continuation_state` column** — Continuation owns that state itself; duplicating it would create an unsolvable consistency problem.
|
|
718
|
+
|
|
719
|
+
> **Note (v1):** lifecycle notifications are not deduplicated across retries. A job that fails, retries, and fails again may produce multiple `failed` notifications. This is documented behavior; idempotency is on the roadmap.
|
|
720
|
+
|
|
721
|
+
---
|
|
722
|
+
|
|
723
|
+
## Adapter Compatibility
|
|
724
|
+
|
|
725
|
+
Queue-adapter agnostic. Tested against Solid Queue, GoodJob, and Sidekiq in the CI matrix (Ruby 3.3 and 3.4):
|
|
726
|
+
|
|
727
|
+
| Adapter | Database | Notes |
|
|
728
|
+
|---|---|---|
|
|
729
|
+
| **Solid Queue** | Postgres | Queue persisted in DB; drain via `SolidQueue::ReadyExecution` |
|
|
730
|
+
| **GoodJob** | Postgres | Queue persisted in DB; drain via `GoodJob.perform_inline` |
|
|
731
|
+
| **Sidekiq** | SQLite (any) | Queue in Redis; drained via `Sidekiq.testing!(:fake)` + `drain_all` |
|
|
732
|
+
|
|
733
|
+
Works with any ActiveJob adapter that integrates with `ActiveSupport::Notifications` (which is essentially all of them). The gem does not branch on adapter type anywhere in `lib/` — the AS::Notifications projection is identical regardless of which adapter runs the job.
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## Testing
|
|
738
|
+
|
|
739
|
+
Notificare integrates with Rails' standard test helpers. Use `ActiveJob::TestHelper#perform_enqueued_jobs` to drive jobs end-to-end:
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
class ImportJobTest < ActiveJob::TestCase
|
|
743
|
+
include ActiveJob::TestHelper
|
|
744
|
+
|
|
745
|
+
test "writes execution and notification rows" do
|
|
746
|
+
perform_enqueued_jobs do
|
|
747
|
+
ImportJob.perform_later(imports(:big).id, recipient: users(:alice))
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
execution = ActiveJob::Notificare::Execution.last
|
|
751
|
+
assert_equal "completed", execution.status
|
|
752
|
+
|
|
753
|
+
notification = ActiveJob::Notificare::Notification.last
|
|
754
|
+
assert_equal "completed", notification.event_type
|
|
755
|
+
assert_equal users(:alice), notification.recipient
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
> **Heads-up — broadcast tests:** in inline mode, `enqueue.active_job` fires *after* `perform.active_job`. When asserting broadcasts, call `perform_enqueued_jobs` **without a block** so the enqueue event lands first and rows exist when the job runs. Use two consecutive calls when testing notification broadcasts: the first runs the job (which queues `BroadcastStreamJob`), the second runs the broadcast job.
|
|
761
|
+
|
|
762
|
+
### Failure & recovery scenarios
|
|
763
|
+
|
|
764
|
+
The gem ships a dedicated test suite (`test/active_job/notificare/failure_recovery_test.rb`) that locks down all ERD §9 failure scenarios:
|
|
765
|
+
|
|
766
|
+
| Scenario | What is asserted |
|
|
767
|
+
|---|---|
|
|
768
|
+
| Worker killed mid-step | No duplicate execution row; `progress_current` preserved; `current_step` accurate after resume |
|
|
769
|
+
| Concurrent `advance!` + status transition | All increments recorded (`update_all` SQL atomicity); status consistent |
|
|
770
|
+
| Case 1 — no `tracks_progress` | Zero rows in both `active_job_executions` and `active_job_notifications` after full lifecycle |
|
|
771
|
+
| Case 2 — indeterminate progress | `progress_total` stays `nil`; execution row indicates spinner mode |
|
|
772
|
+
| Case 3 — resume reuses row | Same DB row found after kill and re-enqueue; `started_at` not reset |
|
|
773
|
+
| Case 4 — missing `recipient:` | `ArgumentError` raised; job class absent from `queue_adapter.enqueued_jobs` |
|
|
774
|
+
| Case 5 — manual notify after completion | Row written; Turbo broadcast fires on recipient inbox stream |
|
|
775
|
+
| v1 duplicate notifications | Retried failures accumulate `failed` rows (documented non-idempotent v1 behavior) |
|
|
776
|
+
|
|
777
|
+
> **`assert_no_enqueued_jobs` gotcha:** `enqueue.active_job` fires in `ensure`, so even a job that raises in `around_enqueue` creates an Execution row and triggers a `Turbo::Streams::BroadcastStreamJob`. Use `assert_no_enqueued_jobs(only: MyJobClass)` when asserting that a specific job was rejected by the adapter — the unfiltered form counts the `BroadcastStreamJob` and fails spuriously.
|
|
778
|
+
|
|
779
|
+
### Running the gem's own test suite
|
|
780
|
+
|
|
781
|
+
```bash
|
|
782
|
+
bundle exec rake test # full suite (enforces 95% coverage)
|
|
783
|
+
bundle exec rubocop # lint
|
|
784
|
+
bundle exec rubocop -a # autocorrect
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## Playing with the gem locally
|
|
790
|
+
|
|
791
|
+
The `test/dummy/` directory is a full Rails 8.1 app wired up with Notificare, Solid Queue, and Turbo. It is the fastest way to explore the gem without a host app.
|
|
792
|
+
|
|
793
|
+
### Setup
|
|
794
|
+
|
|
795
|
+
```bash
|
|
796
|
+
cd test/dummy
|
|
797
|
+
bundle install
|
|
798
|
+
bin/rails db:migrate
|
|
799
|
+
bin/rails db:setup # create + migrate + seed
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
The seed script creates two users (Alice and Bob) with a set of executions and notifications covering every state: completed, running, failed, enqueued, unread, read, and custom step-level.
|
|
803
|
+
|
|
804
|
+
### Start the server
|
|
805
|
+
|
|
806
|
+
```bash
|
|
807
|
+
bin/rails server
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
Then open:
|
|
811
|
+
|
|
812
|
+
- **Admin UI** — <http://localhost:3000/notificare> — paginated executions list and per-execution detail with live progress.
|
|
813
|
+
- **Alice's inbox** — <http://localhost:3000/home?user_id=1>
|
|
814
|
+
- **Bob's inbox** — <http://localhost:3000/home?user_id=2>
|
|
815
|
+
|
|
816
|
+
The home page renders `active_job_notificare` (progress widget) and `active_job_notifications` (inbox) for the given user.
|
|
817
|
+
|
|
818
|
+
### Enqueue a job from the console
|
|
819
|
+
|
|
820
|
+
```bash
|
|
821
|
+
bin/rails console
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
```ruby
|
|
825
|
+
alice = User.first
|
|
826
|
+
|
|
827
|
+
# Lifecycle notifications (completed + failed)
|
|
828
|
+
NotifyOnTestJob.perform_later(recipient: alice)
|
|
829
|
+
FailingNotifyOnTestJob.perform_later(recipient: alice)
|
|
830
|
+
|
|
831
|
+
# Step-level notifications with progress tracking
|
|
832
|
+
StepNotifyTestJob.perform_later(recipient: alice)
|
|
833
|
+
|
|
834
|
+
# Manual notify() call mid-job
|
|
835
|
+
ManualNotifyTestJob.perform_later(recipient: alice)
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
Jobs run inline in development (`:async` adapter). Refresh the inbox page to see new notifications land.
|
|
839
|
+
|
|
840
|
+
### Reset to a clean slate
|
|
841
|
+
|
|
842
|
+
```bash
|
|
843
|
+
bin/rails db:seed:replant # truncate + re-seed
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## Contributing
|
|
849
|
+
|
|
850
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/joaoGabriel55/notificare.
|
|
851
|
+
|
|
852
|
+
1. Fork the repo and create your branch from `main`.
|
|
853
|
+
2. Run `bundle install` and `bundle exec rake test` to make sure the suite is green.
|
|
854
|
+
3. Add tests for any new behavior — coverage must stay ≥ 95%.
|
|
855
|
+
4. Run `bundle exec rubocop` before committing.
|
|
856
|
+
5. Open a PR with a clear description of the change.
|
|
857
|
+
|
|
858
|
+
**Design rule:** prefer adding less. The gem's public API is intentionally tiny; anything new should be designed as if it might one day be absorbed into ActiveJob core. *"Would this feel reasonable in Rails itself?"* If not, simplify or remove.
|
|
859
|
+
|
|
860
|
+
---
|
|
861
|
+
|
|
862
|
+
## Releases
|
|
863
|
+
|
|
864
|
+
| Version | Status | Notes |
|
|
865
|
+
|---|---|---|
|
|
866
|
+
| `0.1.0.alpha.N` | Public preview | Breaking changes allowed; feedback wanted. Install with `gem "notificare", "~> 0.1.0.alpha"` or `gem install notificare --pre`. |
|
|
867
|
+
| `0.1.0` | First stable cut | Breaking changes thereafter follow SemVer. |
|
|
868
|
+
|
|
869
|
+
To cut a release:
|
|
870
|
+
|
|
871
|
+
```bash
|
|
872
|
+
# 1. Bump version + changelog on a release branch
|
|
873
|
+
$EDITOR lib/active_job/notificare/version.rb CHANGELOG.md
|
|
874
|
+
git commit -am "Release 0.1.0.alpha.1"
|
|
875
|
+
|
|
876
|
+
# 2. Tag and push — the release workflow takes over
|
|
877
|
+
git tag v0.1.0.alpha.1
|
|
878
|
+
git push origin main --tags
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
The GitHub Actions release workflow (`release.yml`) uses [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC) — no long-lived API keys required.
|
|
882
|
+
|
|
883
|
+
To yank a bad release:
|
|
884
|
+
|
|
885
|
+
```bash
|
|
886
|
+
gem yank notificare -v 0.1.0.alpha.1
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
---
|
|
890
|
+
|
|
891
|
+
## License
|
|
892
|
+
|
|
893
|
+
Released under the [MIT License](LICENSE).
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## Inspirations
|
|
898
|
+
|
|
899
|
+
Active Storage. Action Mailbox. Solid Queue. Mission Control Jobs.
|