Skip to content

Web Push delivery service + per-device fan-out (CIRCLE-39)#109

Merged
HamptonMakes merged 1 commit into
mainfrom
hampton/circle-39-web-push-delivery
May 12, 2026
Merged

Web Push delivery service + per-device fan-out (CIRCLE-39)#109
HamptonMakes merged 1 commit into
mainfrom
hampton/circle-39-web-push-delivery

Conversation

@HamptonMakes
Copy link
Copy Markdown
Collaborator

Stacked on top of #108. Foundation in #107 shipped subscriptions, the service worker, and the Settings UI. This PR is the part that actually sends notifications: when a Notification is created (reply, mention, new comment, agent reply, status change), we fan out one job per active browser subscription belonging to the recipient and POST a VAPID-signed payload to each.

Linear: CIRCLE-39

What's here

CoPlan::WebPush::Deliver — wraps the web-push gem, signs with the configured VAPID keypair, returns:

  • :delivered (2xx) → bumps notifications_delivered_count and last_delivered_at
  • :expired (404 / 410) → caller destroys the row
  • raises PushServiceError / TooManyRequests for transient failures so SolidQueue can retry

CoPlan::WebPush::PayloadForNotification{title, body, url, tag} per Notification:

  • Title is reason-aware ("Alice replied on My Plan", "Alice mentioned you on My Plan", etc.)
  • Body strips mention chips ([@bob](mention:bob)@bob) and markdown emphasis, then truncates to 140 chars with . Hyphens and # are intentionally preserved (regression test) so co-worker and https://example.com/foo-bar#baz survive.
  • URL is engine-relative — the SW resolves it against self.location.origin and the existing notificationclick handler focuses an existing tab on that origin.
  • tag: "comment-thread-#{thread.id}" so a follow-up reply replaces the previous notification rather than stacking.

CoPlan::WebPushDeliveryJob — per-(notification, subscription):

  • Per-subscription so a single bad endpoint doesn't block the user's other devices, and retry/backoff is scoped tightly.
  • retry_on PushServiceError, TooManyRequests — polynomial backoff, 5 attempts.
  • Destroys the subscription when Deliver returns :expired.
  • Defensive find_by returns silently if either record was deleted between enqueue and execution.

Notification#after_commit on: :create — fans out one job per row in user.web_push_subscriptions. Quietly no-ops when CoPlan.configuration.web_push_configured? is false, so deployments without VAPID keys (including the test env) stay silent.

Verification

  • bundle exec rspec834 examples, 0 failures (was 814 before — +20 new specs across delivery, payload, job, and the model hook).
  • New specs include: success path POST shape, 410/404 handling, transient retry, ConfigurationError, per-reason titles, mention chip stripping, hyphen/URL preservation, ellipsis truncation, anonymous-author fallback, fan-out per subscription, no enqueue without subscriptions, no enqueue on update, no enqueue when not configured.
  • Code review (code_review tool) addressed before commit: tightened the body-strip regex (was corrupting hyphens), switched to String#truncate, removed a misleading test comment.

Out of scope (deliberately)

  • Encouragement banner → CIRCLE-40
  • Production VAPID wiring in coplan-square → CIRCLE-41
  • :mention notification event for the mention path — already wired here via ProcessMentions creating Notification rows with reason: "mention", but CIRCLE-42 covers any cross-system event work that may still be needed.
  • Per-event preferences (mentions only? replies only?) — punt until we see real noise data.

Generated with Amp

Foundation in #107 stops at "subscriptions are stored, SW is wired up,
Settings card works." This commit lights it up — the server actually
sends a push to every device a user has enabled, anchored to the
Notification model so reply / new_comment / mention / agent_response /
status_change all flow through the same path.

Engine
- CoPlan::WebPush::Deliver — wraps web-push gem; signs with VAPID;
  returns :delivered or :expired (404/410); re-raises transient errors
  so SolidQueue can retry.
- CoPlan::WebPush::PayloadForNotification — builds {title,body,url,tag}
  per Notification; reason-aware phrasing; mention chips rewritten to
  @username; markdown emphasis stripped; body truncated to 140 chars
  with an ellipsis; hyphens and # preserved (regression test included)
  so co-worker / URL#fragment survive intact; URL is engine-relative so
  the SW resolves it against self.location.origin.
- CoPlan::WebPushDeliveryJob — per-(notification, subscription) so a
  single bad endpoint doesn't block the user's other devices; destroys
  the subscription on :expired; retry_on PushServiceError /
  TooManyRequests up to 5 times with polynomial backoff; defensive
  against either record being deleted before the job runs.
- Notification#after_commit on :create fans out one job per active
  subscription belonging to the recipient. Quietly no-ops when
  web_push_configured? is false (host hasn't set VAPID).

Specs (+20 examples)
- Deliver: 2xx records delivery, 410/404 returns :expired, 503 re-raises,
  ConfigurationError when VAPID is unset.
- PayloadForNotification: per-reason titles, mention/markdown stripping,
  hyphen/URL preservation, truncation with ellipsis, anonymous fallback.
- WebPushDeliveryJob: calls Deliver with the right args, destroys on
  :expired, no-ops on missing records.
- Notification: fan-out enqueues one job per subscription on create,
  zero when no subscriptions, zero on update, zero when web push isn't
  configured.

Full suite: 834 examples, 0 failures.

Generated with Amp

Amp-Thread-ID: https://ampcode.com/threads/T-019df459-b110-726a-97e2-ff15e2903435
Co-authored-by: Amp <amp@ampcode.com>
@HamptonMakes HamptonMakes marked this pull request as ready for review May 12, 2026 13:39
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e3b5ed44b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +22 to +26
subscription = WebPushSubscription.find_by(id: subscription_id)
return unless subscription

payload = WebPush::PayloadForNotification.call(notification)
result = WebPush::Deliver.call(subscription: subscription, payload: payload)
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 👍 / 👎.

# 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 👍 / 👎.

@HamptonMakes HamptonMakes merged commit a0ffc8e into main May 12, 2026
5 checks passed
@HamptonMakes HamptonMakes deleted the hampton/circle-39-web-push-delivery branch May 12, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant