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
data/README.md ADDED
@@ -0,0 +1,899 @@
1
+ # Notificare
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/notificare)](https://rubygems.org/gems/notificare)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)](https://www.ruby-lang.org/)
5
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%208.1-CC0000)](https://rubyonrails.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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.