thecore_backend_commons 3.2.11 → 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: fe73ef2835e94090bd037724805658c43bb87820a68d2fc86eadd35747ef8e94
4
- data.tar.gz: 707da7e03c6f3bad7499c95fa8616e0f43ff75feed46ebe0159ec05a0594f06f
3
+ metadata.gz: 00717650bd9fe40c41bdf1c30e6372a2abbd095f461b3d1271048e9e408cb8ea
4
+ data.tar.gz: b613ac4217fb65df2b934dc289b6d4167eb19d0f0605ab44914bb9996385467c
5
5
  SHA512:
6
- metadata.gz: 047fff2d4b57e67c7320df0e8d38874603084e91cab99d8e5802807bcf903f75c503360fa5e4845692876906db4118f720457f631c9a51cfbaf65099ed0a8a39
7
- data.tar.gz: 10d926f3d871d4adb4ce2cc7a721b930d32a608ceb2505fc37e07f1408c0a96fe7f7e1d23c065a55e35d344d3433e2dc4fc1c7cb2778e5550079eeea56031ce4
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
 
@@ -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?
@@ -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
@@ -1,3 +1,3 @@
1
1
  module ThecoreBackendCommons
2
- VERSION = "3.2.11".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"
@@ -15,6 +16,7 @@ require "thecore_backend_commons/version"
15
16
  require "thecore_backend_commons/engine"
16
17
  require "thecore_backend_commons/smtp_config"
17
18
  require "thecore_backend_commons/smtp_tester"
19
+ require "thecore_backend_commons/push_notification_service"
18
20
 
19
21
  module ThecoreBackendCommons
20
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.11
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,10 +220,13 @@ 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
211
231
  - lib/thecore_backend_commons/smtp_tester.rb
212
232
  - lib/thecore_backend_commons/version.rb