notificare 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +899 -0
  4. data/app/assets/stylesheets/active_job/notificare/engine.css +425 -0
  5. data/app/controllers/active_job/notificare/application_controller.rb +7 -0
  6. data/app/controllers/active_job/notificare/executions_controller.rb +41 -0
  7. data/app/controllers/active_job/notificare/notifications_controller.rb +72 -0
  8. data/app/helpers/active_job/notificare/view_helpers.rb +43 -0
  9. data/app/models/active_job/notificare/application_record.rb +7 -0
  10. data/app/models/active_job/notificare/execution.rb +20 -0
  11. data/app/models/active_job/notificare/notification.rb +50 -0
  12. data/app/views/active_job/notificare/_notification.html.erb +19 -0
  13. data/app/views/active_job/notificare/_notifications.html.erb +7 -0
  14. data/app/views/active_job/notificare/_progress.html.erb +17 -0
  15. data/app/views/active_job/notificare/executions/index.html.erb +75 -0
  16. data/app/views/active_job/notificare/executions/show.html.erb +66 -0
  17. data/app/views/active_job/notificare/notifications/clear.turbo_stream.erb +3 -0
  18. data/app/views/active_job/notificare/notifications/dismiss.turbo_stream.erb +1 -0
  19. data/app/views/active_job/notificare/notifications/read.turbo_stream.erb +3 -0
  20. data/app/views/layouts/active_job/notificare/application.html.erb +42 -0
  21. data/config/locales/en.yml +7 -0
  22. data/config/routes.rb +13 -0
  23. data/lib/active_job/notificare/concern.rb +78 -0
  24. data/lib/active_job/notificare/engine.rb +28 -0
  25. data/lib/active_job/notificare/progress_handle.rb +23 -0
  26. data/lib/active_job/notificare/projection.rb +145 -0
  27. data/lib/active_job/notificare/recipient.rb +39 -0
  28. data/lib/active_job/notificare/step_dsl.rb +42 -0
  29. data/lib/active_job/notificare/version.rb +5 -0
  30. data/lib/active_job/notificare.rb +14 -0
  31. data/lib/generators/active_job/notificare/install/install_generator.rb +56 -0
  32. data/lib/generators/active_job/notificare/install/templates/_notification.html.erb.tt +19 -0
  33. data/lib/generators/active_job/notificare/install/templates/_notifications.html.erb.tt +7 -0
  34. data/lib/generators/active_job/notificare/install/templates/_progress.html.erb.tt +17 -0
  35. data/lib/generators/active_job/notificare/install/templates/create_active_job_notificare_tables.rb.tt +36 -0
  36. data/lib/generators/active_job/notificare/install/templates/initializer.rb.tt +24 -0
  37. data/lib/generators/active_job/notificare/scaffold/scaffold_generator.rb +74 -0
  38. data/lib/generators/active_job/notificare/scaffold/templates/controller.rb.tt +31 -0
  39. data/lib/generators/active_job/notificare/scaffold/templates/index.html.erb.tt +26 -0
  40. data/lib/generators/active_job/notificare/scaffold/templates/locale.en.yml.tt +18 -0
  41. data/lib/generators/active_job/notificare/scaffold/templates/show.html.erb.tt +39 -0
  42. data/lib/notificare.rb +4 -0
  43. metadata +118 -0
@@ -0,0 +1,425 @@
1
+ /* ActiveJob::Notificare · engine.css
2
+ Design: "Doina" — Romanian folk-art palette
3
+ Dacian sun-wheels · Byzantine gold · madder-dyed wool · Carpathian night */
4
+
5
+ /* ══ Tokens — light theme (warm parchment & crimson) ════════════════════════ */
6
+ :root {
7
+ --nf-bg: #F2EACC; /* linen/bumbac */
8
+ --nf-surface: #FEFBEE; /* ivory */
9
+ --nf-border: #D4BF8A; /* flax */
10
+ --nf-text: #291407; /* walnut */
11
+ --nf-muted: #7D5530; /* earth */
12
+ --nf-accent: #7C1416; /* madder crimson */
13
+ --nf-gold: #AA7A10; /* Byzantine gold */
14
+ --nf-accent-fg: #FEFBEE;
15
+ --nf-radius: 2px;
16
+ --nf-shadow: 0 1px 6px rgba(41, 20, 7, 0.10);
17
+
18
+ /* status — folk-textile hues */
19
+ --nf-enqueued-bg: #E8DFCC; --nf-enqueued-fg: #4E3518;
20
+ --nf-running-bg: #C4D8EE; --nf-running-fg: #0A2840;
21
+ --nf-done-bg: #BAD8C4; --nf-done-fg: #0C3018;
22
+ --nf-fail-bg: #ECC4C0; --nf-fail-fg: #480A0A;
23
+ --nf-custom-bg: #ECD8A4; --nf-custom-fg: #483800;
24
+
25
+ color-scheme: light;
26
+ }
27
+
28
+ /* ══ Tokens — dark theme (Carpathian night & embers) ════════════════════════ */
29
+ [data-theme="dark"] {
30
+ --nf-bg: #150A02; /* night */
31
+ --nf-surface: #221208; /* dark wood */
32
+ --nf-border: #4A2E12; /* wood grain */
33
+ --nf-text: #EEDDBB; /* candlelight */
34
+ --nf-muted: #B88A50; /* amber */
35
+ --nf-accent: #C83C3E; /* ember crimson */
36
+ --nf-gold: #D09E18; /* golden icon */
37
+ --nf-accent-fg: #150A02;
38
+ --nf-shadow: 0 1px 8px rgba(0, 0, 0, 0.55);
39
+
40
+ --nf-enqueued-bg: #382810; --nf-enqueued-fg: #C8A868;
41
+ --nf-running-bg: #081C38; --nf-running-fg: #70B8E8;
42
+ --nf-done-bg: #0A2818; --nf-done-fg: #72C888;
43
+ --nf-fail-bg: #380808; --nf-fail-fg: #E87878;
44
+ --nf-custom-bg: #381E00; --nf-custom-fg: #E8C060;
45
+
46
+ color-scheme: dark;
47
+ }
48
+
49
+ /* ══ Reset ═══════════════════════════════════════════════════════════════════ */
50
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
51
+
52
+ /* ══ Base ════════════════════════════════════════════════════════════════════ */
53
+ body {
54
+ background: var(--nf-bg);
55
+ color: var(--nf-text);
56
+ font-family: system-ui, -apple-system, sans-serif;
57
+ font-size: 14px;
58
+ line-height: 1.6;
59
+ min-height: 100vh;
60
+ }
61
+
62
+ a { color: var(--nf-accent); text-decoration: none; }
63
+ a:hover { color: var(--nf-gold); text-decoration: underline; }
64
+
65
+ /* ══ Header ══════════════════════════════════════════════════════════════════ */
66
+ .nf-header {
67
+ background: var(--nf-surface);
68
+ border-bottom: 1px solid var(--nf-border);
69
+ padding: 0 24px;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ height: 48px;
74
+ }
75
+
76
+ .nf-brand {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 8px;
80
+ font-weight: 700;
81
+ font-size: 15px;
82
+ color: var(--nf-text);
83
+ letter-spacing: 0.02em;
84
+ text-decoration: none;
85
+ }
86
+ .nf-brand:hover { color: var(--nf-accent); text-decoration: none; }
87
+ .nf-brand__mark { color: var(--nf-gold); font-size: 13px; line-height: 1; }
88
+
89
+ /* ══ Folk strip ══════════════════════════════════════════════════════════════
90
+ Repeating diamond (argyle) — dominant motif in Romanian weaving & embroidery.
91
+ Crimson diamonds on Byzantine gold, echoing the "ie" blouse border patterns. */
92
+ .nf-folk-strip {
93
+ height: 10px;
94
+ background-color: var(--nf-gold);
95
+ background-image:
96
+ linear-gradient( 45deg, var(--nf-accent) 25%, transparent 25%),
97
+ linear-gradient(-45deg, var(--nf-accent) 25%, transparent 25%),
98
+ linear-gradient( 45deg, transparent 75%, var(--nf-accent) 75%),
99
+ linear-gradient(-45deg, transparent 75%, var(--nf-accent) 75%);
100
+ background-size: 10px 10px;
101
+ background-position: 0 0, 0 5px, 5px -5px, -5px 0;
102
+ }
103
+
104
+ /* ══ Theme toggle ════════════════════════════════════════════════════════════ */
105
+ .nf-theme-toggle {
106
+ background: none;
107
+ border: 1px solid var(--nf-border);
108
+ border-radius: var(--nf-radius);
109
+ color: var(--nf-muted);
110
+ cursor: pointer;
111
+ font-size: 15px;
112
+ height: 30px;
113
+ width: 30px;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ flex-shrink: 0;
118
+ }
119
+ .nf-theme-toggle:hover { border-color: var(--nf-gold); color: var(--nf-gold); }
120
+
121
+ /* ══ Layout ══════════════════════════════════════════════════════════════════ */
122
+ .nf-main { max-width: 1200px; margin: 0 auto; padding: 28px 24px; }
123
+
124
+ h1 {
125
+ font-size: 20px;
126
+ font-weight: 700;
127
+ margin-bottom: 4px;
128
+ letter-spacing: -0.01em;
129
+ }
130
+
131
+ .nf-subtitle {
132
+ color: var(--nf-muted);
133
+ font-size: 13px;
134
+ margin-bottom: 20px;
135
+ }
136
+
137
+ /* ══ Card ════════════════════════════════════════════════════════════════════ */
138
+ .nf-card {
139
+ background: var(--nf-surface);
140
+ border: 1px solid var(--nf-border);
141
+ border-radius: var(--nf-radius);
142
+ box-shadow: var(--nf-shadow);
143
+ margin-bottom: 16px;
144
+ overflow: hidden;
145
+ }
146
+
147
+ .nf-card__header {
148
+ padding: 10px 16px;
149
+ border-bottom: 1px solid var(--nf-border);
150
+ border-left: 3px solid var(--nf-gold);
151
+ font-weight: 600;
152
+ font-size: 11px;
153
+ text-transform: uppercase;
154
+ letter-spacing: 0.08em;
155
+ color: var(--nf-muted);
156
+ background: var(--nf-bg);
157
+ }
158
+
159
+ .nf-card__body { padding: 16px; }
160
+
161
+ /* ══ Table ═══════════════════════════════════════════════════════════════════ */
162
+ .nf-table { width: 100%; border-collapse: collapse; }
163
+
164
+ .nf-table th {
165
+ background: var(--nf-bg);
166
+ border-bottom: 2px solid var(--nf-border);
167
+ padding: 9px 12px;
168
+ text-align: left;
169
+ font-weight: 600;
170
+ font-size: 11px;
171
+ text-transform: uppercase;
172
+ letter-spacing: 0.08em;
173
+ color: var(--nf-muted);
174
+ white-space: nowrap;
175
+ }
176
+
177
+ .nf-table td {
178
+ border-bottom: 1px solid var(--nf-border);
179
+ padding: 10px 12px;
180
+ vertical-align: middle;
181
+ }
182
+ .nf-table tr:last-child td { border-bottom: 0; }
183
+ .nf-table tbody tr:hover td { background: var(--nf-bg); }
184
+
185
+ /* ══ Monospace ═══════════════════════════════════════════════════════════════ */
186
+ .nf-mono {
187
+ font-family: ui-monospace, 'Cascadia Code', monospace;
188
+ font-size: 12px;
189
+ color: var(--nf-muted);
190
+ }
191
+
192
+ /* ══ Status badge ════════════════════════════════════════════════════════════ */
193
+ .nf-badge {
194
+ display: inline-block;
195
+ padding: 2px 8px;
196
+ border-radius: var(--nf-radius);
197
+ font-size: 11px;
198
+ font-weight: 700;
199
+ text-transform: uppercase;
200
+ letter-spacing: 0.06em;
201
+ }
202
+ .nf-badge--enqueued { background: var(--nf-enqueued-bg); color: var(--nf-enqueued-fg); }
203
+ .nf-badge--running { background: var(--nf-running-bg); color: var(--nf-running-fg); }
204
+ .nf-badge--completed { background: var(--nf-done-bg); color: var(--nf-done-fg); }
205
+ .nf-badge--failed { background: var(--nf-fail-bg); color: var(--nf-fail-fg); }
206
+ .nf-badge--custom { background: var(--nf-custom-bg); color: var(--nf-custom-fg); }
207
+
208
+ /* ══ Filter form ═════════════════════════════════════════════════════════════ */
209
+ .nf-filters {
210
+ display: flex;
211
+ gap: 8px;
212
+ align-items: center;
213
+ margin-bottom: 16px;
214
+ flex-wrap: wrap;
215
+ }
216
+ .nf-filters select {
217
+ padding: 6px 10px;
218
+ border: 1px solid var(--nf-border);
219
+ border-radius: var(--nf-radius);
220
+ background: var(--nf-surface);
221
+ color: var(--nf-text);
222
+ font-size: 13px;
223
+ cursor: pointer;
224
+ }
225
+ .nf-filters select:focus { outline: 2px solid var(--nf-gold); outline-offset: 1px; }
226
+ .nf-filters button {
227
+ padding: 6px 16px;
228
+ background: var(--nf-accent);
229
+ color: var(--nf-accent-fg);
230
+ border: none;
231
+ border-radius: var(--nf-radius);
232
+ cursor: pointer;
233
+ font-size: 13px;
234
+ font-weight: 600;
235
+ }
236
+ .nf-filters button:hover { opacity: 0.88; }
237
+ .nf-filters .nf-clear { padding: 6px 10px; color: var(--nf-muted); font-size: 13px; }
238
+ .nf-filters .nf-clear:hover { color: var(--nf-accent); text-decoration: none; }
239
+
240
+ /* ══ Pagination ══════════════════════════════════════════════════════════════ */
241
+ .nf-pagination {
242
+ display: flex;
243
+ gap: 4px;
244
+ align-items: center;
245
+ margin-top: 20px;
246
+ justify-content: center;
247
+ }
248
+ .nf-pagination a,
249
+ .nf-pagination span {
250
+ padding: 5px 12px;
251
+ border: 1px solid var(--nf-border);
252
+ border-radius: var(--nf-radius);
253
+ font-size: 13px;
254
+ }
255
+ .nf-pagination a { color: var(--nf-accent); }
256
+ .nf-pagination a:hover { background: var(--nf-bg); border-color: var(--nf-gold); text-decoration: none; }
257
+ .nf-pagination__current {
258
+ background: var(--nf-accent) !important;
259
+ color: var(--nf-accent-fg) !important;
260
+ border-color: var(--nf-accent) !important;
261
+ }
262
+ .nf-pagination__info { margin-top: 8px; text-align: center; color: var(--nf-muted); font-size: 12px; }
263
+
264
+ /* ══ Detail layout ═══════════════════════════════════════════════════════════ */
265
+ .nf-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
266
+ @media (max-width: 700px) { .nf-detail { grid-template-columns: 1fr; } }
267
+
268
+ /* ══ Key/value list ══════════════════════════════════════════════════════════ */
269
+ .nf-kv {
270
+ display: grid;
271
+ grid-template-columns: max-content 1fr;
272
+ gap: 8px 16px;
273
+ align-items: baseline;
274
+ }
275
+ .nf-kv dt {
276
+ color: var(--nf-muted);
277
+ font-size: 11px;
278
+ font-weight: 600;
279
+ text-transform: uppercase;
280
+ letter-spacing: 0.06em;
281
+ white-space: nowrap;
282
+ }
283
+ .nf-kv dd {
284
+ font-family: ui-monospace, monospace;
285
+ font-size: 12px;
286
+ word-break: break-all;
287
+ color: var(--nf-text);
288
+ }
289
+
290
+ /* ══ Error block ═════════════════════════════════════════════════════════════ */
291
+ .nf-error {
292
+ background: var(--nf-fail-bg);
293
+ color: var(--nf-fail-fg);
294
+ border-left: 3px solid var(--nf-fail-fg);
295
+ border-radius: 0 var(--nf-radius) var(--nf-radius) 0;
296
+ padding: 10px 14px;
297
+ font-family: ui-monospace, monospace;
298
+ font-size: 12px;
299
+ margin-top: 14px;
300
+ word-break: break-word;
301
+ }
302
+
303
+ /* ══ Empty state ═════════════════════════════════════════════════════════════ */
304
+ .nf-empty {
305
+ padding: 48px 16px;
306
+ text-align: center;
307
+ color: var(--nf-muted);
308
+ }
309
+ .nf-empty::before {
310
+ content: '✦';
311
+ display: block;
312
+ font-size: 22px;
313
+ color: var(--nf-border);
314
+ margin-bottom: 12px;
315
+ }
316
+
317
+ /* ══ Back link ═══════════════════════════════════════════════════════════════ */
318
+ .nf-back {
319
+ display: inline-flex;
320
+ align-items: center;
321
+ gap: 4px;
322
+ margin-bottom: 16px;
323
+ color: var(--nf-muted);
324
+ font-size: 13px;
325
+ }
326
+ .nf-back:hover { color: var(--nf-accent); text-decoration: none; }
327
+
328
+ /* ══ Progress widget (notificare-progress__*) ════════════════════════════════ */
329
+ .notificare-progress {
330
+ display: flex;
331
+ flex-direction: column;
332
+ gap: 8px;
333
+ }
334
+ .notificare-progress__bar {
335
+ width: 100%;
336
+ height: 6px;
337
+ border-radius: 3px;
338
+ border: none;
339
+ background: var(--nf-border);
340
+ appearance: none;
341
+ }
342
+ .notificare-progress__bar::-webkit-progress-bar { background: var(--nf-border); border-radius: 3px; }
343
+ .notificare-progress__bar::-webkit-progress-value { background: var(--nf-accent); border-radius: 3px; }
344
+ .notificare-progress__bar::-moz-progress-bar { background: var(--nf-accent); border-radius: 3px; }
345
+ .notificare-progress__label {
346
+ font-size: 12px;
347
+ color: var(--nf-muted);
348
+ font-family: ui-monospace, monospace;
349
+ }
350
+ .notificare-progress__step {
351
+ font-size: 12px;
352
+ color: var(--nf-text);
353
+ font-weight: 500;
354
+ }
355
+ .notificare-progress__spinner {
356
+ display: inline-block;
357
+ width: 18px;
358
+ height: 18px;
359
+ border: 2px solid var(--nf-border);
360
+ border-top-color: var(--nf-accent);
361
+ border-radius: 50%;
362
+ animation: nf-spin 0.7s linear infinite;
363
+ }
364
+ @keyframes nf-spin { to { transform: rotate(360deg); } }
365
+
366
+ /* ══ Notification inbox (notificare-inbox / notificare-notification__*) ══════ */
367
+ .notificare-inbox {
368
+ display: flex;
369
+ flex-direction: column;
370
+ gap: 8px;
371
+ }
372
+ .notificare-inbox > form > button {
373
+ background: none;
374
+ border: 1px solid var(--nf-border);
375
+ border-radius: var(--nf-radius);
376
+ color: var(--nf-muted);
377
+ cursor: pointer;
378
+ font-size: 12px;
379
+ padding: 4px 12px;
380
+ margin-bottom: 4px;
381
+ }
382
+ .notificare-inbox > form > button:hover { border-color: var(--nf-fail-fg); color: var(--nf-fail-fg); }
383
+
384
+ .notificare-notification {
385
+ border: 1px solid var(--nf-border);
386
+ border-radius: var(--nf-radius);
387
+ padding: 12px 14px;
388
+ background: var(--nf-surface);
389
+ }
390
+ .notificare-notification--unread { border-left: 3px solid var(--nf-accent); }
391
+
392
+ .notificare-notification__title {
393
+ display: block;
394
+ font-size: 13px;
395
+ font-weight: 600;
396
+ color: var(--nf-text);
397
+ margin-bottom: 4px;
398
+ }
399
+ .notificare-notification__description {
400
+ font-size: 12px;
401
+ color: var(--nf-muted);
402
+ margin-bottom: 8px;
403
+ }
404
+ .notificare-notification__actions {
405
+ display: flex;
406
+ gap: 6px;
407
+ flex-wrap: wrap;
408
+ }
409
+ .notificare-notification__actions button,
410
+ .notificare-notification__actions a {
411
+ background: none;
412
+ border: 1px solid var(--nf-border);
413
+ border-radius: var(--nf-radius);
414
+ color: var(--nf-muted);
415
+ cursor: pointer;
416
+ font-size: 11px;
417
+ font-weight: 500;
418
+ padding: 3px 10px;
419
+ text-decoration: none;
420
+ }
421
+ .notificare-notification__actions button:hover,
422
+ .notificare-notification__actions a:hover {
423
+ border-color: var(--nf-accent);
424
+ color: var(--nf-accent);
425
+ }
@@ -0,0 +1,7 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class ApplicationController < ActiveJob::Notificare.parent_controller.constantize
4
+ protect_from_forgery with: :exception
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class ExecutionsController < ApplicationController
4
+ PER_PAGE = 25
5
+
6
+ before_action :authenticate_notificare!
7
+
8
+ def index
9
+ scope = Execution.recent
10
+ scope = scope.where(status: params[:status]) if params[:status].present?
11
+ scope = scope.where(job_class: params[:job_class]) if params[:job_class].present?
12
+
13
+ @page = [ params.fetch(:page, 1).to_i, 1 ].max
14
+ @total_count = scope.count
15
+ @total_pages = [ (@total_count.to_f / PER_PAGE).ceil, 1 ].max
16
+ @page = [ @page, @total_pages ].min
17
+ @executions = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
18
+ @statuses = Execution.statuses.keys
19
+ @job_classes = Execution.distinct.pluck(:job_class).sort
20
+ end
21
+
22
+ def show
23
+ @execution = Execution.find(params[:id])
24
+ @notifications = Notification.where(job_id: @execution.job_id).limit(50)
25
+ rescue ActiveRecord::RecordNotFound
26
+ head :not_found
27
+ end
28
+
29
+ private
30
+
31
+ def authenticate_notificare!
32
+ proc = ActiveJob::Notificare.authenticate_with
33
+ if proc.nil?
34
+ head :forbidden if Rails.env.production?
35
+ elsif !instance_exec(&proc)
36
+ head :forbidden
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,72 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class NotificationsController < ApplicationController
4
+ before_action :set_current_recipient
5
+ before_action :set_notification, only: [ :read, :dismiss ]
6
+
7
+ def read
8
+ @notification.mark_read!
9
+ respond_to do |format|
10
+ format.turbo_stream
11
+ format.html { head :ok }
12
+ end
13
+ end
14
+
15
+ def dismiss
16
+ @notification.dismiss!
17
+ respond_to do |format|
18
+ format.turbo_stream
19
+ format.html { head :ok }
20
+ end
21
+ end
22
+
23
+ def clear
24
+ @notifications = Notification.where(recipient: @current_recipient).visible.to_a
25
+ Notification.where(id: @notifications.map(&:id)).destroy_all
26
+ respond_to do |format|
27
+ format.turbo_stream
28
+ format.html { head :ok }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def set_current_recipient
35
+ @current_recipient = resolve_current_recipient
36
+ head :unauthorized if @current_recipient.nil?
37
+ end
38
+
39
+ def resolve_current_recipient
40
+ if (proc = ActiveJob::Notificare.current_recipient_proc)
41
+ instance_exec(&proc)
42
+ elsif respond_to?(:current_notificare_recipient, true)
43
+ current_notificare_recipient
44
+ elsif respond_to?(:current_user, true)
45
+ current_user
46
+ else
47
+ raise NotImplementedError, <<~MSG
48
+ Could not resolve the current recipient for ActiveJob::Notificare.
49
+
50
+ To fix this, do one of the following:
51
+
52
+ 1. Override `current_notificare_recipient` in your ApplicationController:
53
+
54
+ def current_notificare_recipient
55
+ current_user # or however you expose the signed-in user
56
+ end
57
+
58
+ 2. Set a proc in an initializer:
59
+
60
+ ActiveJob::Notificare.current_recipient_proc = -> { current_user }
61
+ MSG
62
+ end
63
+ end
64
+
65
+ def set_notification
66
+ @notification = Notification.where(recipient: @current_recipient).find(params[:id])
67
+ rescue ActiveRecord::RecordNotFound
68
+ head :not_found
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ module ViewHelpers
4
+ def active_job_notificare(execution)
5
+ render partial: "active_job/notificare/progress", locals: { execution: execution }
6
+ end
7
+
8
+ def active_job_notifications(for: nil)
9
+ recipient = binding.local_variable_get(:for)
10
+ notifications = Notification.where(recipient: recipient).visible
11
+ render partial: "active_job/notificare/notifications", locals: { notifications: notifications, recipient: recipient }
12
+ end
13
+
14
+ # In engine context (engine's own controller views or tests with engine routes included),
15
+ # the bare route helpers like read_notification_path are defined on self.
16
+ # In host app views they are not, so we fall back to url_for with the full controller
17
+ # path, which the host app's route set resolves to the correct mounted prefix.
18
+ def notificare_read_notification_path(notification)
19
+ if respond_to?(:read_notification_path)
20
+ read_notification_path(notification)
21
+ else
22
+ url_for(controller: "active_job/notificare/notifications", action: "read", id: notification.to_param, only_path: true)
23
+ end
24
+ end
25
+
26
+ def notificare_dismiss_notification_path(notification)
27
+ if respond_to?(:dismiss_notification_path)
28
+ dismiss_notification_path(notification)
29
+ else
30
+ url_for(controller: "active_job/notificare/notifications", action: "dismiss", id: notification.to_param, only_path: true)
31
+ end
32
+ end
33
+
34
+ def notificare_clear_notifications_path
35
+ if respond_to?(:clear_notifications_path)
36
+ clear_notifications_path
37
+ else
38
+ url_for(controller: "active_job/notificare/notifications", action: "clear", only_path: true)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class ApplicationRecord < ::ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class Execution < ApplicationRecord
4
+ self.table_name = "active_job_executions"
5
+
6
+ if defined?(Turbo::Broadcastable)
7
+ include Turbo::Broadcastable
8
+ broadcasts_refreshes_to ->(execution) { [ "active_job_progress", execution.job_id ] }
9
+ end
10
+
11
+ enum :status, { enqueued: "enqueued", running: "running", completed: "completed", failed: "failed" }
12
+
13
+ scope :recent, -> { order(created_at: :desc) }
14
+
15
+ validates :job_id, presence: true, uniqueness: true
16
+ validates :job_class, presence: true
17
+ validates :status, presence: true
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ module ActiveJob
2
+ module Notificare
3
+ class Notification < ApplicationRecord
4
+ self.table_name = "active_job_notifications"
5
+
6
+ if defined?(Turbo::Broadcastable)
7
+ include Turbo::Broadcastable
8
+ after_commit :broadcast_notification_refresh
9
+ end
10
+
11
+ belongs_to :recipient, polymorphic: true
12
+
13
+ enum :event_type, { completed: "completed", failed: "failed", custom: "custom" }
14
+
15
+ attribute :metadata, :json, default: nil
16
+ attribute :actions, :json, default: nil
17
+
18
+ default_scope { order(created_at: :desc) }
19
+
20
+ scope :unread, -> { where(read_at: nil) }
21
+ scope :visible, -> { where(dismissed_at: nil) }
22
+
23
+ validates :event_type, presence: true
24
+ validates :title, presence: true
25
+
26
+ def read?
27
+ read_at.present?
28
+ end
29
+
30
+ def dismissed?
31
+ dismissed_at.present?
32
+ end
33
+
34
+ def mark_read!
35
+ update!(read_at: Time.current) unless read?
36
+ end
37
+
38
+ def dismiss!
39
+ update!(dismissed_at: Time.current) unless dismissed?
40
+ end
41
+
42
+ private
43
+
44
+ def broadcast_notification_refresh
45
+ return unless recipient
46
+ broadcast_refresh_later_to "active_job_notifications", recipient.to_gid_param
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ <%= turbo_frame_tag dom_id(notification) do %>
2
+ <div class="notificare-notification<%= notification.read? ? "" : " notificare-notification--unread" %>">
3
+ <strong class="notificare-notification__title"><%= notification.title %></strong>
4
+ <% if notification.description.present? %>
5
+ <p class="notificare-notification__description"><%= notification.description %></p>
6
+ <% end %>
7
+ <div class="notificare-notification__actions">
8
+ <% unless notification.read? %>
9
+ <%= button_to t("active_job.notificare.notifications.mark_as_read"), notificare.read_notification_path(notification), method: :patch %>
10
+ <% end %>
11
+ <%= button_to t("active_job.notificare.notifications.dismiss"), notificare.dismiss_notification_path(notification), method: :patch %>
12
+ <% if notification.actions.present? %>
13
+ <% notification.actions.each do |action| %>
14
+ <%= link_to action["label"], action["url"] %>
15
+ <% end %>
16
+ <% end %>
17
+ </div>
18
+ </div>
19
+ <% end %>