Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions engine/app/jobs/coplan/web_push_delivery_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require "web-push"

module CoPlan
# Delivers one Notification's Web Push payload to one subscription.
#
# Per-subscription rather than per-notification so that a single bad
# endpoint (rate limited, briefly down, etc.) doesn't block delivery to
# the user's other devices, and so retries / backoff are scoped tightly.
class WebPushDeliveryJob < ApplicationJob
queue_as :default

# SolidQueue retries everything else with backoff. We don't retry the
# known terminal cases below: ExpiredSubscription / InvalidSubscription
# are handled inside Deliver and surface as :expired.
retry_on ::WebPush::PushServiceError, wait: :polynomially_longer, attempts: 5
retry_on ::WebPush::TooManyRequests, wait: :polynomially_longer, attempts: 5

def perform(notification_id:, subscription_id:)
notification = Notification.find_by(id: notification_id)
return unless notification

subscription = WebPushSubscription.find_by(id: subscription_id)
return unless subscription

payload = WebPush::PayloadForNotification.call(notification)
result = WebPush::Deliver.call(subscription: subscription, payload: payload)
Comment on lines +22 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify subscription still belongs to notification recipient

WebPushDeliveryJob#perform fetches a subscription by ID and sends immediately, but never checks that it is still owned by the notification’s user. This can leak notifications across accounts because WebPushSubscription.upsert_for reassigns an existing endpoint_digest row to whatever user most recently subscribes on that browser/device; if that reassignment happens before the queued job runs, the payload is delivered to the wrong user’s device. Guard on subscription.user_id == notification.user_id (or scope the lookup by both IDs) before calling deliver.

Useful? React with 👍 / 👎.

subscription.destroy if result == :expired
end
end
end
18 changes: 18 additions & 0 deletions engine/app/models/coplan/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class Notification < ApplicationRecord
scope :read, -> { where.not(read_at: nil) }
scope :newest_first, -> { order(created_at: :desc) }

after_commit :enqueue_web_push_deliveries, on: :create

def read?
read_at.present?
end
Expand All @@ -28,5 +30,21 @@ def self.ransackable_attributes(auth_object = nil)
def self.ransackable_associations(auth_object = nil)
%w[user plan comment_thread comment]
end

private

# Fan-out one WebPushDeliveryJob per active subscription belonging to the
# recipient. Quietly skips when Web Push isn't configured (host hasn't
# set VAPID keys) or the recipient hasn't subscribed any browsers yet.
def enqueue_web_push_deliveries
return unless CoPlan.configuration.web_push_configured?

user.web_push_subscriptions.find_each do |subscription|
WebPushDeliveryJob.perform_later(
notification_id: id,
subscription_id: subscription.id
)
end
end
end
end
59 changes: 59 additions & 0 deletions engine/app/services/coplan/web_push/deliver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
require "web-push"

module CoPlan
module WebPush
# Sends a single push payload to a single browser subscription using the
# configured VAPID key pair. Returns one of:
#
# :delivered - push service accepted the message (2xx)
# :expired - subscription is gone (404 / 410); caller should destroy it
#
# Anything else (transient 5xx, rate limiting, network errors) raises so
# SolidQueue can retry with backoff.
class Deliver
def self.call(subscription:, payload:)
new(subscription: subscription, payload: payload).call
end

def initialize(subscription:, payload:)
@subscription = subscription
@payload = payload
end

def call
unless CoPlan.configuration.web_push_configured?
raise ConfigurationError, "Web Push VAPID keys are not configured"
end

::WebPush.payload_send(
endpoint: @subscription.endpoint,
p256dh: @subscription.p256dh_key,
auth: @subscription.auth_key,
message: @payload.to_json,
vapid: vapid_options,
ttl: 24 * 60 * 60, # 24h — push service drops the message after this
urgency: "normal"
)

@subscription.record_delivery!
:delivered
rescue ::WebPush::InvalidSubscription, ::WebPush::ExpiredSubscription
# Browser unsubscribed or endpoint was rotated. Tell the caller to
# destroy the row so we don't keep trying.
:expired
end

class ConfigurationError < StandardError; end

private

def vapid_options
{
subject: CoPlan.configuration.vapid_subject,
public_key: CoPlan.configuration.vapid_public_key,
private_key: CoPlan.configuration.vapid_private_key
}
end
end
end
end
77 changes: 77 additions & 0 deletions engine/app/services/coplan/web_push/payload_for_notification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
module CoPlan
module WebPush
# Builds the { title, body, url, tag } hash the service worker shows for a
# given Notification. Title/body shape per reason; URL deep-links to the
# specific thread; tag groups updates for the same thread so successive
# replies replace each other rather than piling up.
class PayloadForNotification
BODY_TRUNCATE = 140

def self.call(notification)
new(notification).call
end

def initialize(notification)
@notification = notification
@plan = notification.plan
@thread = notification.comment_thread
@comment = notification.comment || @thread.comments.order(:created_at).first
end

def call
{
title: title,
body: body,
url: url,
tag: "comment-thread-#{@thread.id}"
}
end

private

def title
case @notification.reason
when "mention"
"#{actor_name} mentioned you on #{@plan.title}"
when "reply"
"#{actor_name} replied on #{@plan.title}"
when "new_comment"
"#{actor_name} commented on #{@plan.title}"
when "agent_response"
"Agent updated a thread on #{@plan.title}"
when "status_change"
"Thread updated on #{@plan.title}"
else
"Update on #{@plan.title}"
end
end

def body
return "" unless @comment&.body_markdown

# Strip mention chips and the markdown emphasis/quote/code characters
# that don't render usefully as plain text in an OS notification.
# Leave hyphens and `#` alone so co-worker / URL#fragment / -prefix
# text stays intact.
text = @comment.body_markdown
.gsub(/\[@(\w+)\]\(mention:[^)]+\)/, '@\1')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip mention chips for dotted and dashed usernames

The mention-chip cleanup regex only matches \w+, so push bodies fail to normalize valid usernames containing . or - (for example [@jane.doe](mention:jane.doe)), leaving raw markdown syntax in notifications. This is inconsistent with the app’s username and mention parsing rules, which allow . and -; use the same pattern ([\w.-]+ with a backreference) to preserve readable @username text.

Useful? React with 👍 / 👎.

.gsub(/[*_`>]/, " ")
.gsub(/\s+/, " ")
.strip
text.truncate(BODY_TRUNCATE, omission: "…")
end

def url
# Relative path is fine — the SW resolves against self.location.origin
# when opening / focusing the notification target tab.
CoPlan::Engine.routes.url_helpers.plan_path(@plan, thread: @thread.id)
end

def actor_name
author = @comment&.author
return "Someone" unless author.respond_to?(:name) && author.name.present?
author.name
end
end
end
end
79 changes: 79 additions & 0 deletions spec/jobs/coplan/web_push_delivery_job_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require "rails_helper"

RSpec.describe CoPlan::WebPushDeliveryJob, type: :job do
let(:user) { create(:coplan_user) }
let(:subscription) { create(:coplan_web_push_subscription, user: user) }
let(:plan) { create(:plan, created_by_user: user) }
let(:thread) do
create(:comment_thread,
plan: plan,
plan_version: plan.current_plan_version,
created_by_user: user)
end
let!(:comment) do
thread.comments.create!(
author_type: "human",
author_id: user.id,
body_markdown: "Hi"
)
end
let(:notification) do
create(:notification,
user: user, plan: plan, comment_thread: thread, comment: comment, reason: "reply")
end

before do
CoPlan.configuration.vapid_public_key = "pub"
CoPlan.configuration.vapid_private_key = "priv"
CoPlan.configuration.vapid_subject = "mailto:test@example.com"
end

after do
CoPlan.configuration.vapid_public_key = nil
CoPlan.configuration.vapid_private_key = nil
CoPlan.configuration.vapid_subject = nil
end

describe "#perform" do
it "calls Deliver with the notification's payload and the subscription" do
allow(CoPlan::WebPush::Deliver).to receive(:call).and_return(:delivered)

described_class.perform_now(notification_id: notification.id, subscription_id: subscription.id)

expect(CoPlan::WebPush::Deliver).to have_received(:call).with(
subscription: subscription,
payload: hash_including(:title, :body, :url, :tag)
)
end

it "destroys the subscription when delivery reports :expired" do
# Eagerly create both rows so the change matcher only counts what the
# job itself does, not the let-driven setup.
subscription_id = subscription.id
notification_id = notification.id
allow(CoPlan::WebPush::Deliver).to receive(:call).and_return(:expired)

expect {
described_class.perform_now(notification_id: notification_id, subscription_id: subscription_id)
}.to change(CoPlan::WebPushSubscription, :count).by(-1)
end

it "no-ops if the notification was already deleted" do
notification_id = notification.id
notification.destroy!

expect {
described_class.perform_now(notification_id: notification_id, subscription_id: subscription.id)
}.not_to raise_error
end

it "no-ops if the subscription was already deleted" do
subscription_id = subscription.id
subscription.destroy!

expect {
described_class.perform_now(notification_id: notification.id, subscription_id: subscription_id)
}.not_to raise_error
end
end
end
74 changes: 74 additions & 0 deletions spec/models/coplan/notification_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require "rails_helper"

RSpec.describe CoPlan::Notification do
let(:user) { create(:coplan_user) }
let(:plan) { create(:plan, created_by_user: user) }
let(:thread) do
create(:comment_thread,
plan: plan,
plan_version: plan.current_plan_version,
created_by_user: user)
end
let(:attrs) do
{ user: user, plan: plan, comment_thread: thread, reason: "reply" }
end

describe "after_create web push fan-out" do
context "when web push is configured" do
before do
CoPlan.configuration.vapid_public_key = "pub"
CoPlan.configuration.vapid_private_key = "priv"
CoPlan.configuration.vapid_subject = "mailto:test@example.com"
end

after do
CoPlan.configuration.vapid_public_key = nil
CoPlan.configuration.vapid_private_key = nil
CoPlan.configuration.vapid_subject = nil
end

it "enqueues one delivery job per subscription on create" do
sub_a = create(:coplan_web_push_subscription, user: user)
sub_b = create(:coplan_web_push_subscription, user: user)

expect {
described_class.create!(attrs)
}.to have_enqueued_job(CoPlan::WebPushDeliveryJob).twice

notification = described_class.last
[sub_a, sub_b].each do |sub|
expect(CoPlan::WebPushDeliveryJob).to have_been_enqueued.with(
notification_id: notification.id,
subscription_id: sub.id
)
end
end

it "enqueues nothing when the recipient has no subscriptions" do
expect {
described_class.create!(attrs)
}.not_to have_enqueued_job(CoPlan::WebPushDeliveryJob)
end

it "does not enqueue on update" do
create(:coplan_web_push_subscription, user: user)
notification = described_class.create!(attrs)

expect {
notification.mark_read!
}.not_to have_enqueued_job(CoPlan::WebPushDeliveryJob)
end
end

context "when web push is not configured" do
it "does not enqueue any jobs even if subscriptions exist" do
# Sanity: in test env VAPID keys are nil by default.
create(:coplan_web_push_subscription, user: user)

expect {
described_class.create!(attrs)
}.not_to have_enqueued_job(CoPlan::WebPushDeliveryJob)
end
end
end
end
Loading
Loading