thecore_backend_commons 3.2.10 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c3ab89916ec1ac0d08617605d32e5ad30b2bc8232df838a29a9b75966f0d653
4
- data.tar.gz: 89ea95480895118d1641d964fae91339e3714cb126e3d0ae3343b83e9c18c289
3
+ metadata.gz: 00717650bd9fe40c41bdf1c30e6372a2abbd095f461b3d1271048e9e408cb8ea
4
+ data.tar.gz: b613ac4217fb65df2b934dc289b6d4167eb19d0f0605ab44914bb9996385467c
5
5
  SHA512:
6
- metadata.gz: 5e11ad2f433fd264b679cd7f77ad77363f75d0c5f4245027ab4b39e85190f45bb78af8624a5621334833a73615846084c5478375f0d7dae4622319c6225ddf9e
7
- data.tar.gz: 7fa46bd7592d9d85db5778db94d2130d7c70771aea9118f089dd05822646e9602515b1d1ede8080280be79cf168939fc594a1daf60cec04ff370c501c38829f0
6
+ metadata.gz: c6e95d2f7f3ad718c6a1722cc679be17761179f5dac5c0dbabe9590403f3c297a66f67223366871fe35a78d5786ad73699feb30a1028d76603310526fa20cccf
7
+ data.tar.gz: b7d31a43dbc06cb21846674ba6d3f74bed4d2061b7f70c21c823612a13bc5af14d350d0881bd21f9750b0417d53679008405ec9dac204c3c1134dc2e6b6ff041
data/README.md CHANGED
@@ -1,4 +1,130 @@
1
- This is part of Thecore framework: https://github.com/gabrieletassoni/thecore/tree/release/3
1
+ This is part of [Thecore framework](https://github.com/gabrieletassoni/thecore/tree/release/3).
2
+
3
+ ---
4
+
5
+ ## Web Push notifications (VAPID)
6
+
7
+ Self-contained browser push notifications — no Firebase, no APNs, no third-party account required. The gem provides the server-side half: models, dispatch service, and ActionCable channel. The client integration guide (React + service worker) lives in the [`model_driven_api` README](../model_driven_api/README.md#web-push-vapid-from-a-react-client).
8
+
9
+ ### How it works
10
+
11
+ ```
12
+ Browser Rails backend
13
+ │ │
14
+ │── POST subscribe ─────────►│ PushSubscriber.subscribe_for(user, endpoint:, p256dh:, auth:)
15
+ │ │
16
+ │◄─ ActionCable stream ──────│ PushNotificationChannel streams push_notifications_subscriber_N
17
+ │ │
18
+ │ [backend sends push] │ PushNotificationService.dispatch(subscriber, message)
19
+ │◄─ Web Push payload ────────│ → Webpush.payload_send (VAPID)
20
+ │ │ → message.sent_at = now
21
+ │── POST acknowledge ────────►│ message.update!(received_at: / read_at:)
22
+ ```
23
+
24
+ ### Server configuration
25
+
26
+ VAPID keys are generated automatically the first time `rails db:seed` runs. You only need to set the contact email via RailsAdmin → Settings:
27
+
28
+ | ThecoreSettings key (`ns: :vapid`) | Purpose | Default |
29
+ |------------------------------------|---------|---------|
30
+ | `public_key` | VAPID public key (base64url) — send to browsers | generated at seed |
31
+ | `private_key` | VAPID private key — never expose to clients | generated at seed |
32
+ | `contact_email` | `mailto:` URI in VAPID `sub` claim | `""` (set this) |
33
+ | `max_messages_per_subscriber` | PushMessage retention cap per subscriber | `"500"` |
34
+
35
+ > **Regenerating VAPID keys invalidates all existing `PushSubscriber` records.** Every registered browser must re-subscribe.
36
+
37
+ ### Models
38
+
39
+ #### `PushSubscriber`
40
+
41
+ Represents one browser/device subscription registered by a `User`.
42
+
43
+ | Column | Type | Notes |
44
+ |--------|------|-------|
45
+ | `user_id` | bigint | FK to users |
46
+ | `endpoint` | text | Unique; push service URL provided by the browser |
47
+ | `p256dh` | string | ECDH public key (base64url) |
48
+ | `auth` | string | Auth secret (base64url) |
49
+ | `user_agent` | string | Browser/OS identifier |
50
+ | `expired_at` | datetime | `nil` = active; set when the push service returns 410 |
51
+
52
+ ```ruby
53
+ # Upsert by endpoint (re-registering an existing browser updates the record)
54
+ PushSubscriber.subscribe_for(user, endpoint:, p256dh:, auth:, user_agent:)
55
+
56
+ # Scope: only active (not expired)
57
+ PushSubscriber.active
58
+
59
+ # Expire a subscriber (called automatically on 410 from push service)
60
+ subscriber.expire!
61
+ ```
62
+
63
+ #### `PushMessage`
64
+
65
+ Records a notification payload and its lifecycle.
66
+
67
+ | Column | Type | Notes |
68
+ |--------|------|-------|
69
+ | `push_subscriber_id` | bigint | FK |
70
+ | `title` | string | Required |
71
+ | `body` | text | Required |
72
+ | `url` | string | URL to open on notification click (optional) |
73
+ | `icon` | string | Notification icon URL (optional) |
74
+ | `sent_at` | datetime | Populated by `PushNotificationService` on successful dispatch |
75
+ | `received_at` | datetime | Set by client via `acknowledge` endpoint |
76
+ | `read_at` | datetime | Set by client via `acknowledge` endpoint |
77
+
78
+ Old messages beyond `vapid.max_messages_per_subscriber` are pruned automatically after each dispatch (oldest first).
79
+
80
+ ### `PushNotificationService`
81
+
82
+ ```ruby
83
+ ThecoreBackendCommons::PushNotificationService.dispatch(subscriber, message)
84
+ ```
85
+
86
+ 1. Calls `Webpush.payload_send` with the subscriber's keys and the VAPID credentials from `ThecoreSettings`.
87
+ 2. On success: sets `message.sent_at = Time.current`.
88
+ 3. On `Webpush::ExpiredSubscription` or `Webpush::InvalidSubscription` (HTTP 410/404 from push service): calls `subscriber.expire!`.
89
+ 4. Prunes oldest `PushMessage` records if the subscriber exceeds the retention cap.
90
+ 5. Always returns `message` — errors are rescued and logged, never propagated.
91
+
92
+ ### `PushNotificationChannel` (ActionCable)
93
+
94
+ ```ruby
95
+ # In your connection.rb — current_user must be set
96
+ class ApplicationCable::Connection < ActionCable::Connection::Base
97
+ identified_by :current_user
98
+ # ... authenticate and set current_user
99
+ end
100
+ ```
101
+
102
+ Subscribe from the frontend:
103
+
104
+ ```javascript
105
+ // Stream for one subscriber (most common)
106
+ consumer.subscriptions.create(
107
+ { channel: "PushNotificationChannel", subscriber_id: subscriberId },
108
+ { received(data) { /* handle message */ } }
109
+ );
110
+
111
+ // Stream for all active subscribers of the current user
112
+ consumer.subscriptions.create(
113
+ { channel: "PushNotificationChannel", user_id: currentUserId },
114
+ { received(data) { /* handle message */ } }
115
+ );
116
+ ```
117
+
118
+ The channel only streams to `subscriber_id` values that belong to `current_user` — unauthorized subscriber IDs are silently ignored.
119
+
120
+ Broadcast from anywhere in the backend:
121
+
122
+ ```ruby
123
+ PushNotificationChannel.broadcast_to(subscriber, message)
124
+ # Sends message.as_json to "push_notifications_subscriber_#{subscriber.id}"
125
+ ```
126
+
127
+ ---
2
128
 
3
129
  ## Invio email e configurazione SMTP
4
130
 
@@ -55,12 +181,20 @@ ThecoreBackendCommons::SmtpConfig.setting(:from)
55
181
 
56
182
  ### Testare la configurazione SMTP
57
183
 
58
- Dall'applicazione Rails che include questo gem, eseguire:
184
+ Il gem espone `ThecoreBackendCommons::SmtpTester` e un rake task, disponibili automaticamente in qualsiasi app che include questo gem.
185
+
186
+ **Da rake task (shell):**
59
187
 
60
188
  ```bash
61
- bundle exec rails runner script/test_smtp.rb destinatario@example.com
189
+ rails thecore_backend_commons:smtp:test[destinatario@example.com]
190
+ ```
191
+
192
+ **Da Rails console:**
193
+
194
+ ```ruby
195
+ ThecoreBackendCommons::SmtpTester.call("destinatario@example.com")
62
196
  ```
63
197
 
64
- Se non si passa un indirizzo, l'email viene inviata all'indirizzo configurato in `ThecoreSettings mytask.default_email`.
198
+ Se non si passa un indirizzo, in entrambi i casi viene usato `ThecoreSettings mytask.default_email`.
65
199
 
66
- Lo script stampa i parametri SMTP effettivamente usati (inclusi `tls` e `enable_starttls_auto`) prima di tentare la consegna, e restituisce exit code `1` in caso di errore con il messaggio dell'eccezione.
200
+ Lo tester stampa i parametri SMTP effettivamente usati (inclusi `tls` e `enable_starttls_auto`) prima di tentare la consegna. Restituisce `true`/`false` dal console e exit code `1` dal rake task in caso di errore.
@@ -0,0 +1,18 @@
1
+ class PushNotificationChannel < ApplicationCable::Channel
2
+ def subscribed
3
+ if params[:subscriber_id].present?
4
+ subscriber = PushSubscriber.active.find_by(id: params[:subscriber_id], user_id: current_user.id)
5
+ stream_from "push_notifications_subscriber_#{subscriber.id}" if subscriber
6
+ elsif params[:user_id].present? && params[:user_id].to_i == current_user.id
7
+ PushSubscriber.active.where(user_id: current_user.id).each do |sub|
8
+ stream_from "push_notifications_subscriber_#{sub.id}"
9
+ end
10
+ end
11
+ end
12
+
13
+ def unsubscribed; end
14
+
15
+ def self.broadcast_to(subscriber, message)
16
+ ActionCable.server.broadcast("push_notifications_subscriber_#{subscriber.id}", message.as_json)
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ class PushMessage < ApplicationRecord
2
+ belongs_to :push_subscriber
3
+ validates :title, presence: true
4
+ validates :body, presence: true
5
+ end
@@ -0,0 +1,21 @@
1
+ class PushSubscriber < ApplicationRecord
2
+ belongs_to :user
3
+ has_many :push_messages, dependent: :destroy
4
+ validates :endpoint, presence: true, uniqueness: true
5
+ scope :active, -> { where(expired_at: nil) }
6
+
7
+ def expire!
8
+ update!(expired_at: Time.current)
9
+ end
10
+
11
+ def self.subscribe_for(user, endpoint:, p256dh: nil, auth: nil, user_agent: nil)
12
+ record = find_or_initialize_by(endpoint: endpoint)
13
+ record.user = user
14
+ record.p256dh = p256dh
15
+ record.auth = auth
16
+ record.user_agent = user_agent
17
+ record.expired_at = nil
18
+ record.save!
19
+ record
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ class CreatePushSubscribers < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :push_subscribers do |t|
4
+ t.bigint :user_id, null: false
5
+ t.text :endpoint, null: false
6
+ t.string :p256dh
7
+ t.string :auth
8
+ t.string :user_agent
9
+ t.datetime :expired_at
10
+ t.timestamps
11
+ end
12
+ add_index :push_subscribers, :endpoint, unique: true
13
+ add_index :push_subscribers, :user_id
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ class CreatePushMessages < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :push_messages do |t|
4
+ t.bigint :push_subscriber_id, null: false
5
+ t.string :title, null: false
6
+ t.text :body, null: false
7
+ t.string :url
8
+ t.string :icon
9
+ t.datetime :sent_at
10
+ t.datetime :received_at
11
+ t.datetime :read_at
12
+ t.timestamps
13
+ end
14
+ add_index :push_messages, :push_subscriber_id
15
+ end
16
+ end
data/db/seeds.rb CHANGED
@@ -10,4 +10,15 @@ Thecore::Seed.save_setting :smtp, :domain, ""
10
10
  Thecore::Seed.save_setting :smtp, :user_name, ""
11
11
  Thecore::Seed.save_setting :smtp, :password, ""
12
12
  Thecore::Seed.save_setting :smtp, :authentication, ""
13
- Thecore::Seed.save_setting :smtp, :enable_starttls_auto, ""
13
+ Thecore::Seed.save_setting :smtp, :enable_starttls_auto, ""
14
+
15
+ puts "Loading ThecoreBackendCommons VAPID config"
16
+ require "web-push"
17
+ unless ThecoreSettings::Setting.where(ns: :vapid, key: :public_key).where.not(raw: [nil, ""]).exists?
18
+ vapid_key = WebPush.generate_key
19
+ Thecore::Seed.save_setting :vapid, :public_key, vapid_key.public_key
20
+ Thecore::Seed.save_setting :vapid, :private_key, vapid_key.private_key
21
+ puts " Generated new VAPID key pair"
22
+ end
23
+ Thecore::Seed.save_setting :vapid, :contact_email, "" unless ThecoreSettings::Setting.where(ns: :vapid, key: :contact_email).exists?
24
+ Thecore::Seed.save_setting :vapid, :max_messages_per_subscriber, "500" unless ThecoreSettings::Setting.where(ns: :vapid, key: :max_messages_per_subscriber).exists?
@@ -1,4 +1,11 @@
1
- # desc "Explaining what the task does"
2
- # task :thecore_backend_commons do
3
- # # Task goes here
4
- # end
1
+ namespace :thecore_backend_commons do
2
+ namespace :smtp do
3
+ desc "Send a test email using ThecoreSettings SMTP config. " \
4
+ "Usage: rails thecore_backend_commons:smtp:test[recipient@example.com] " \
5
+ "(omit argument to use mytask.default_email)"
6
+ task :test, [:recipient] => :environment do |_t, args|
7
+ success = ThecoreBackendCommons::SmtpTester.call(args[:recipient])
8
+ exit 1 unless success
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,58 @@
1
+ module ThecoreBackendCommons
2
+ class PushNotificationService
3
+ MAX_MESSAGES_DEFAULT = 500
4
+
5
+ def self.dispatch(subscriber, message)
6
+ new(subscriber, message).dispatch
7
+ end
8
+
9
+ def initialize(subscriber, message)
10
+ @subscriber = subscriber
11
+ @message = message
12
+ end
13
+
14
+ def dispatch
15
+ send_push
16
+ prune_old_messages
17
+ @message
18
+ rescue => e
19
+ Rails.logger.error("[PushNotificationService] dispatch failed: #{e.message}")
20
+ @message
21
+ end
22
+
23
+ private
24
+
25
+ def send_push
26
+ WebPush.payload_send(
27
+ message: JSON.generate(payload),
28
+ endpoint: @subscriber.endpoint,
29
+ p256dh: @subscriber.p256dh,
30
+ auth: @subscriber.auth,
31
+ vapid: vapid_options
32
+ )
33
+ @message.update!(sent_at: Time.current)
34
+ rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription
35
+ @subscriber.expire!
36
+ end
37
+
38
+ def payload
39
+ { title: @message.title, body: @message.body, url: @message.url, icon: @message.icon }.compact
40
+ end
41
+
42
+ def vapid_options
43
+ {
44
+ public_key: ThecoreSettings::Setting.where(ns: :vapid, key: :public_key).pluck(:raw).first,
45
+ private_key: ThecoreSettings::Setting.where(ns: :vapid, key: :private_key).pluck(:raw).first,
46
+ subject: "mailto:#{ThecoreSettings::Setting.where(ns: :vapid, key: :contact_email).pluck(:raw).first.presence || 'admin@example.com'}"
47
+ }
48
+ end
49
+
50
+ def prune_old_messages
51
+ limit = ThecoreSettings::Setting.where(ns: :vapid, key: :max_messages_per_subscriber).pluck(:raw).first&.to_i || MAX_MESSAGES_DEFAULT
52
+ count = @subscriber.push_messages.count
53
+ return unless count > limit
54
+ oldest_ids = @subscriber.push_messages.order(created_at: :asc).limit(count - limit).pluck(:id)
55
+ PushMessage.where(id: oldest_ids).delete_all
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThecoreBackendCommons
4
+ # Sends a test email using the SMTP settings from ThecoreSettings.
5
+ # Usable from the Rails console or via the rake task.
6
+ #
7
+ # From rails console:
8
+ # ThecoreBackendCommons::SmtpTester.call("you@example.com")
9
+ #
10
+ # From the shell:
11
+ # rails thecore_backend_commons:smtp:test[you@example.com]
12
+ class SmtpTester
13
+ def self.call(recipient = nil)
14
+ new(recipient).call
15
+ end
16
+
17
+ def initialize(recipient = nil)
18
+ @recipient = recipient.presence ||
19
+ ThecoreSettings::Setting.find_by(ns: :mytask, key: :default_email)&.raw
20
+ end
21
+
22
+ def call
23
+ validate!
24
+ print_settings
25
+ send_mail
26
+ end
27
+
28
+ private
29
+
30
+ def validate!
31
+ raise ArgumentError, "No recipient given and mytask.default_email is not configured." if @recipient.blank?
32
+ raise ArgumentError, "smtp.address is not configured in ThecoreSettings." if opts[:address].blank?
33
+ end
34
+
35
+ def print_settings
36
+ puts "SMTP settings:"
37
+ puts " address: #{opts[:address]}"
38
+ puts " port: #{opts[:port]}"
39
+ puts " domain: #{opts[:domain].inspect}"
40
+ puts " user_name: #{opts[:user_name].inspect}"
41
+ puts " authentication: #{opts[:authentication].inspect}"
42
+ puts " tls: #{opts[:tls]}"
43
+ puts " enable_starttls_auto:#{opts[:enable_starttls_auto]}"
44
+ puts " from: #{SmtpConfig.setting(:from).inspect}"
45
+ puts ""
46
+ puts "Sending test email to: #{@recipient}"
47
+ end
48
+
49
+ def send_mail
50
+ from_address = SmtpConfig.setting(:from).presence || "noreply@mytask.local"
51
+ delivery_opts = opts
52
+
53
+ mail = Mail.new do
54
+ from from_address
55
+ to delivery_opts[:address] # placeholder; overridden below
56
+ subject "[MyTask] SMTP test — #{Time.current.strftime('%Y-%m-%d %H:%M:%S %Z')}"
57
+ body "This is an automated SMTP connectivity test sent from MyTask.\n\n" \
58
+ "If you received this message, the SMTP configuration is working correctly.\n\n" \
59
+ "Settings used:\n" \
60
+ " address: #{delivery_opts[:address]}\n" \
61
+ " port: #{delivery_opts[:port]}\n" \
62
+ " tls: #{delivery_opts[:tls]}\n" \
63
+ " auth: #{delivery_opts[:authentication].inspect}"
64
+ end
65
+ mail.to = @recipient
66
+ mail.delivery_method(:smtp, delivery_opts)
67
+ mail.deliver!
68
+ puts "OK: email delivered successfully."
69
+ true
70
+ rescue StandardError => e
71
+ puts "ERROR: #{e.class}: #{e.message}"
72
+ false
73
+ end
74
+
75
+ def opts
76
+ @opts ||= SmtpConfig.delivery_options
77
+ end
78
+ end
79
+ end
@@ -1,3 +1,3 @@
1
1
  module ThecoreBackendCommons
2
- VERSION = "3.2.10".freeze
2
+ VERSION = "3.3.0".freeze
3
3
  end
@@ -1,4 +1,5 @@
1
1
  require "ostruct"
2
+ require "web-push" # gem for VAPID web push (pushpad/web-push, actively maintained)
2
3
  require "thecore_auth_commons"
3
4
  require "thecore_background_jobs"
4
5
  require "rails-i18n"
@@ -14,6 +15,8 @@ require "seed_dump"
14
15
  require "thecore_backend_commons/version"
15
16
  require "thecore_backend_commons/engine"
16
17
  require "thecore_backend_commons/smtp_config"
18
+ require "thecore_backend_commons/smtp_tester"
19
+ require "thecore_backend_commons/push_notification_service"
17
20
 
18
21
  module ThecoreBackendCommons
19
22
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: thecore_backend_commons
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.10
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriele Tassoni
@@ -177,6 +177,20 @@ dependencies:
177
177
  - - "~>"
178
178
  - !ruby/object:Gem::Version
179
179
  version: '3.4'
180
+ - !ruby/object:Gem::Dependency
181
+ name: web-push
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '3.0'
187
+ type: :runtime
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '3.0'
180
194
  description: Wrapper to keep all the common libraries and setups needed by Thecore
181
195
  UI Backend(s).
182
196
  email:
@@ -188,7 +202,10 @@ files:
188
202
  - README.md
189
203
  - Rakefile
190
204
  - app/channels/activity_log_channel.rb
205
+ - app/channels/push_notification_channel.rb
191
206
  - app/mailers/concerns/smtp_deliverable.rb
207
+ - app/models/push_message.rb
208
+ - app/models/push_subscriber.rb
192
209
  - config/initializers/abilities.rb
193
210
  - config/initializers/add_to_db_migrations.rb
194
211
  - config/initializers/after_initialize.rb
@@ -203,11 +220,15 @@ files:
203
220
  - config/locales/en.yml
204
221
  - config/locales/it.devise.custom.yml
205
222
  - config/locales/it.yml
223
+ - db/migrate/20260616000001_create_push_subscribers.rb
224
+ - db/migrate/20260616000002_create_push_messages.rb
206
225
  - db/seeds.rb
207
226
  - lib/tasks/thecore_backend_commons_tasks.rake
208
227
  - lib/thecore_backend_commons.rb
209
228
  - lib/thecore_backend_commons/engine.rb
229
+ - lib/thecore_backend_commons/push_notification_service.rb
210
230
  - lib/thecore_backend_commons/smtp_config.rb
231
+ - lib/thecore_backend_commons/smtp_tester.rb
211
232
  - lib/thecore_backend_commons/version.rb
212
233
  homepage: https://github.com/gabrieletassoni/thecore_backend_commons
213
234
  licenses: []