Add db table and models

This commit is contained in:
Jason Zimdars
2025-07-14 19:57:30 -05:00
parent 2e7a7384ab
commit e59a994a0c
12 changed files with 327 additions and 25 deletions
+5
View File
@@ -0,0 +1,5 @@
module Push
def self.table_name_prefix
"push_"
end
end
+7
View File
@@ -0,0 +1,7 @@
class Push::Subscription < ApplicationRecord
belongs_to :user
def notification(**params)
WebPush::Notification.new(**params, badge: user.notifications.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
end
end
+1
View File
@@ -17,6 +17,7 @@ class User < ApplicationRecord
has_many :pins, dependent: :destroy
has_many :pinned_cards, through: :pins, source: :card
has_many :commands, dependent: :destroy
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
normalizes :email_address, with: ->(value) { value.strip.downcase }
+1
View File
@@ -9,6 +9,7 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %>
<%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %>
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
+1 -1
View File
@@ -1 +1 @@
hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA==
kK/zGHEsgRGIoP27w3Pvm2UXQTyxJFDlPTgsX0OwazJ32RB118DfCJV7dM9OBLdTpPu9DowQsSjObX893BuQgplwYTAuq9zndq7p1qsxzwv/IMgGeo7Nv+h90lnQVWrp/vwfEBZ/BZ4n448Q+TndWLJzUIL1So5dkMj/swvB2ORE8X+2mkOSa9y2yIx51ro0UZaBOfphy+fZUUBeCK/yTDcqXkgPc4wXpZ3de5XGed5JjeMF2PaCoi7plF2SW8XBsThipmG7N92ojkfiEH7zX12ZUjd3UFTqo2xGgM5NAivbHIev9RcsIjXFuJu/T3I6PbFMEw4cSFCBFCesag4H+74DVkT+3bdWF9H+Ag1/ePYzFDygjWaQDehGDUbeXoz7XehYhLy8hErUWnmh4kbekt9in6EskayBZkyr/Mq2MVBydOywY4oVQuk1bEnM/Akwq/6sVYbtupXfIUIETHpSTM/IyQM0ht4c6cjNKZVM48Cr6+2hgdFN4oEjl5J0FDUNNaI3vfOebNtLv4lcF6f4O8kJ5KNe+iKbD376jSlXfeBcoj9O7kStpGGye54/d/dKH6UVbNQReBf9FbqNxoPKYIN2Uc+Bs0Kgo25FHISLFJstimQcgdJ71djRs6lpKCKX0QzKdt6QGpJJlsBZ7e8T89m71WggX3YSm2aW4aV7tnQmFlJODOcjXAAHaAB4kL1browf9/4tiPnoWFXoaISUqzmYn8n4Ao8xjW2G2bK75gEruUhDaSMBgXNLgG/8isN6Tdw4bSnluNdY6OHIcFDk7GPEbBvUs4C11P30uDLuztbEHTahbmXhkhZ4tpLLpy2y5dcYY37gvFIqsbHCXR5PTA2JrgrCAeUoF+f2sPn/USicJIFBNclppULoF7VnQJyRiwdUXN/T1ZG/CZH769GILEEG--XCcg7yRJcGpr4qyd--pE4nqwEeUck8ZAgJrR3B8A==
+4
View File
@@ -0,0 +1,4 @@
Rails.application.configure do
config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key))
config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key))
end
+44
View File
@@ -0,0 +1,44 @@
require "web-push"
require "web_push/pool"
require "web_push/notification"
Rails.application.configure do
config.x.web_push_pool = WebPush::Pool.new(
invalid_subscription_handler: ->(subscription_id) do
Rails.application.executor.wrap do
Rails.logger.info "Destroying push subscription: #{subscription_id}"
Push::Subscription.find_by(id: subscription_id)&.destroy
end
end
)
at_exit { config.x.web_push_pool.shutdown }
end
module WebPush::PersistentRequest
def perform
if @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
end
req = Net::HTTP::Post.new(uri.request_uri, headers)
req.body = body
if http.is_a?(Net::HTTP::Persistent)
response = http.request uri, req
else
resp = http.request(req)
verify_response(resp)
end
resp
end
end
WebPush::Request.prepend WebPush::PersistentRequest
@@ -0,0 +1,17 @@
class AddPushSubscriptions < ActiveRecord::Migration[8.1]
def change
create_table :push_subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.string :endpoint
t.string :p256dh_key
t.string :auth_key
t.string :user_agent
t.timestamps
t.index [:user_id, :endpoint], unique: true
t.index :endpoint
t.index :user_agent
t.index ["endpoint", "p256dh_key", "auth_key"]
end
end
end
Generated
+17 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do
ActiveRecord::Schema[8.1].define(version: 2025_07_14_222221) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -299,6 +299,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do
t.index ["user_id"], name: "index_pins_on_user_id"
end
create_table "push_subscriptions", force: :cascade do |t|
t.string "auth_key"
t.datetime "created_at", null: false
t.string "endpoint"
t.string "p256dh_key"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576"
t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint"
t.index ["user_agent"], name: "index_push_subscriptions_on_user_agent"
t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true
t.index ["user_id"], name: "index_push_subscriptions_on_user_id"
end
create_table "reactions", force: :cascade do |t|
t.integer "comment_id", null: false
t.string "content", limit: 16, null: false
@@ -416,6 +431,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do
add_foreign_key "notifications", "users", column: "creator_id"
add_foreign_key "pins", "cards"
add_foreign_key "pins", "users"
add_foreign_key "push_subscriptions", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "users"
add_foreign_key "steps", "cards"
+145 -23
View File
@@ -451,7 +451,7 @@ columns:
default_function:
collation:
comment:
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -532,11 +532,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &30 !ruby/object:ActiveModel::Type::Boolean
cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -756,7 +756,7 @@ columns:
comment:
filters_tags:
- *19
- &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -843,6 +843,51 @@ columns:
- *6
- *9
- *25
push_subscriptions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auth_key
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *5
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: endpoint
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: p256dh_key
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *9
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *25
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -941,24 +986,15 @@ columns:
collation:
comment:
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *30
- *25
steps:
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
cast_type: *30
sql_type_metadata: *31
cast_type: *31
sql_type_metadata: *32
'null': false
default: false
default_function:
@@ -981,19 +1017,19 @@ columns:
- *22
- *5
- *6
- *32
- *33
- *9
tags:
- *5
- *6
- *33
- *34
- *9
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *30
sql_type_metadata: *31
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1052,8 +1088,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *30
sql_type_metadata: *31
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1121,6 +1157,7 @@ primary_keys:
mentions: id
notifications: id
pins: id
push_subscriptions: id
reactions: id
schema_migrations: version
search_queries: id
@@ -1165,6 +1202,7 @@ data_sources:
mentions: true
notifications: true
pins: true
push_subscriptions: true
reactions: true
schema_migrations: true
search_queries: true
@@ -2244,6 +2282,90 @@ indexes:
nulls_not_distinct:
comment:
valid: true
push_subscriptions:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: push_subscriptions
name: idx_on_endpoint_p256dh_key_auth_key_7553014576
unique: false
columns:
- endpoint
- p256dh_key
- auth_key
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: push_subscriptions
name: index_push_subscriptions_on_endpoint
unique: false
columns:
- endpoint
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: push_subscriptions
name: index_push_subscriptions_on_user_agent
unique: false
columns:
- user_agent
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: push_subscriptions
name: index_push_subscriptions_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: push_subscriptions
name: index_push_subscriptions_on_user_id_and_endpoint
unique: true
columns:
- user_id
- endpoint
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: reactions
@@ -2516,4 +2638,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250703193928
version: 20250714222221
+29
View File
@@ -0,0 +1,29 @@
class WebPush::Notification
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
@title, @body, @path, @badge = title, body, path, badge
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
end
def deliver(connection: nil)
WebPush.payload_send \
message: encoded_message,
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
vapid: vapid_identification,
connection: connection,
urgency: "high"
end
private
def vapid_identification
{ subject: "mailto:support@37signals.com" }.merge \
Rails.configuration.x.vapid.symbolize_keys
end
def encoded_message
JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } }
end
def icon_path
Rails.application.routes.url_helpers.account_logo_path
end
end
+56
View File
@@ -0,0 +1,56 @@
# This is in lib so we can use it in a thread pool without the Rails executor
class WebPush::Pool
attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler
def initialize(invalid_subscription_handler:)
@delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000)
@invalidation_pool = Concurrent::FixedThreadPool.new(1)
@connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150)
@invalid_subscription_handler = invalid_subscription_handler
end
def queue(payload, subscriptions)
subscriptions.find_each do |subscription|
deliver_later(payload, subscription)
end
end
def shutdown
connection.shutdown
shutdown_pool(delivery_pool)
shutdown_pool(invalidation_pool)
end
private
def deliver_later(payload, subscription)
# Ensure any AR operations happen before we post to the thread pool
notification = subscription.notification(**payload)
subscription_id = subscription.id
delivery_pool.post do
deliver(notification, subscription_id)
rescue Exception => e
Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}"
end
rescue Concurrent::RejectedExecutionError
end
def deliver(notification, id)
notification.deliver(connection: connection)
rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex
invalidate_subscription_later(id) if invalid_subscription_handler
end
def invalidate_subscription_later(id)
invalidation_pool.post do
invalid_subscription_handler.call(id)
rescue Exception => e
Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}"
end
end
def shutdown_pool(pool)
pool.shutdown
pool.kill unless pool.wait_for_termination(1)
end
end