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
6 changes: 6 additions & 0 deletions .env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ NODE_ENV="development"
HONEYBADGER_API_KEY="Optional"
HONEYBADGER_JS_API_KEY="Optional"

# Better Stack for production logs, in addition to stdout. Off unless the token is set.
# Both values are on the source's page in Better Stack; the host defaults to in.logs.betterstack.com.
# (https://betterstack.com/docs/logs/ruby-and-rails/)
# BETTERSTACK_SOURCE_TOKEN=
# BETTERSTACK_INGESTING_HOST=

# AWS for images storages
AWS_ID=
AWS_SECRET=
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ gem "jwt", "2.10.3" # Verify delegated access tokens
gem "kaminari", "~> 1.2" # A Scope and Engine based, clean, powerful, customizable and sophisticated paginator
gem "katex", "~> 0.9.0" # This rubygem enables you to render TeX math to HTML using KaTeX. It uses ExecJS under the hood
gem "liquid", "~> 5.4" # A secure, non-evaling end user template engine with aesthetic markup
gem "logtail-rails", "~> 0.2.12", require: false # Ships Rails logs to Better Stack when BETTERSTACK_SOURCE_TOKEN is set (see lib/betterstack/log_device.rb and config/initializers/betterstack.rb before upgrading)
gem "metainspector", "~> 5.12" # To get and parse website metadata for Open Graph rich objects
gem "mini_magick", "~> 4.13" # Manipulate images with minimal use of memory via ImageMagick / GraphicsMagick
gem "nokogiri", "~> 1.18" # HTML, XML, SAX, and Reader parser
Expand Down Expand Up @@ -105,6 +106,9 @@ gem "rouge", "~> 4.2" # A pure-ruby code highlighter
gem "rss", "~> 0.2.9" # Ruby's standard library for RSS
gem "rubyzip", "~> 2.4" # Rubyzip is a ruby library for reading and writing zip files
gem "s3_direct_upload", "~> 0.1" # Direct Upload to Amazon S3
gem "sentry-rails", "~> 5.19", require: false # Pilot: ships errors to Better Stack (Sentry-protocol compatible); loaded in config/application.rb
gem "sentry-ruby", "~> 5.19", require: false # Pilot: see config/initializers/sentry.rb, runs alongside Honeybadger
gem "sentry-sidekiq", "~> 5.19", require: false # Pilot: reports Sidekiq worker errors and traces jobs; loaded in config/application.rb
gem "sidekiq", "~> 6.5.3" # Sidekiq is used to process background jobs with the help of Redis
gem "sidekiq-throttled", "~> 1.5" # Concurrency control for Sidekiq
gem "sidekiq-cron", "~> 1.7" # Allows execution of scheduled cron jobs as specific times
Expand Down
24 changes: 24 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,17 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
logtail (0.1.17)
msgpack (~> 1.0)
logtail-rack (0.2.6)
logtail (~> 0.1)
rack (>= 1.2, < 4.0)
logtail-rails (0.2.12)
actionpack (>= 5.0.0)
activerecord (>= 5.0.0)
logtail (~> 0.1, >= 0.1.14)
logtail-rack (~> 0.1)
railties (>= 5.0.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
Expand Down Expand Up @@ -952,6 +963,15 @@ GEM
faraday (>= 0.17.3, < 3)
sax-machine (1.3.2)
securerandom (0.4.1)
sentry-rails (5.28.1)
railties (>= 5.0)
sentry-ruby (~> 5.28.1)
sentry-ruby (5.28.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.28.1)
sentry-ruby (~> 5.28.1)
sidekiq (>= 3.0)
sexp_processor (4.17.5)
shellany (0.0.1)
shoulda-matchers (5.3.0)
Expand Down Expand Up @@ -1195,6 +1215,7 @@ DEPENDENCIES
launchy (~> 2.5)
liquid (~> 5.4)
listen (~> 3.7)
logtail-rails (~> 0.2.12)
memory_profiler (~> 1.0)
metainspector (~> 5.12)
mini_magick (~> 4.13)
Expand Down Expand Up @@ -1252,6 +1273,9 @@ DEPENDENCIES
rubyzip (~> 2.4)
s3_direct_upload (~> 0.1)
sassc-rails (~> 2.1.2)
sentry-rails (~> 5.19)
sentry-ruby (~> 5.19)
sentry-sidekiq (~> 5.19)
shoulda-matchers (~> 5.3)
sidekiq (~> 6.5.3)
sidekiq-cron (~> 1.7)
Expand Down
9 changes: 8 additions & 1 deletion app/decorators/notification_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,14 @@ def article_id
end

def article_url
@article_url ||= json_data.dig("article", "url") || json_data.dig("article", "path")
@article_url ||= begin
url = json_data.dig("article", "url")
if url.present? && url.match?(%r{:\d+:\d+})
json_data.dig("article", "path").presence || url.sub(%r{(:\d+):\d+}, '\1')
else
url.presence || json_data.dig("article", "path")
end
end
end

def article_title
Expand Down
10 changes: 9 additions & 1 deletion app/lib/url.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ def self.domain(domain_or_subforem = nil)
def self.url(uri = nil, domain_or_subforem = nil)
base_url = "#{protocol}#{domain(domain_or_subforem)}"
port = dev_port
base_url += ":#{port}" if Rails.env.development? && port.present? && base_url.exclude?(":#{port}")
base_url += ":#{port}" if Rails.env.development? && port.present? && !port_present?(base_url)
return base_url unless uri

Addressable::URI.parse(base_url).join(uri).normalize.to_s
end

def self.port_present?(url)
Addressable::URI.heuristic_parse(url).port.present?
rescue Addressable::URI::InvalidURIError
false
end

private_class_method :port_present?

# Creates an article URL
#
# @param article [Article] the article to create the URL for
Expand Down
13 changes: 12 additions & 1 deletion app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,10 @@ def self.permitted_video_source_url?(url)
after_save :generate_social_image
after_save :generate_context_notes

after_update_commit :notify_co_author_changes, if: proc { |article|
article.published? && article.saved_change_to_co_author_ids?
}

after_update_commit :update_notifications, if: proc { |article|
article.notifications.any? && !article.saved_changes.empty?
}
Expand Down Expand Up @@ -1475,7 +1479,7 @@ def fetch_video_duration
end

def update_notifications
Notification.update_notifications(self, I18n.t("models.article.published"))
Notification.update_notifications(self, %w[Published CoAuthor])
end

def update_notification_subscriptions
Expand Down Expand Up @@ -2037,6 +2041,13 @@ def update_dependent_embeds_if_key_info_changed
end
end

def notify_co_author_changes
before, after = saved_change_to_co_author_ids
removed_user_ids = Array.wrap(before).map(&:to_i) - Array.wrap(after).map(&:to_i)

Notification.send_to_co_authors(self, removed_user_ids)
end

def cleanup_memberships_if_unpublished
concept_memberships.destroy_all
trend_memberships.destroy_all
Expand Down
12 changes: 11 additions & 1 deletion app/models/notification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Notification < ApplicationRecord
before_create :mark_notified_at_time
after_commit :cleanup_old_notifications, on: :create

scope :for_published_articles, -> { where(notifiable_type: "Article", action: "Published") }
scope :for_published_articles, -> { where(notifiable_type: "Article", action: %w[Published CoAuthor]) }
scope :for_comments, -> { where(notifiable_type: "Comment", action: nil) } # nil action means "not a reaction"
scope :for_mentions, -> { where(notifiable_type: "Mention") }

Expand Down Expand Up @@ -73,6 +73,16 @@ def send_to_mentioned_users_and_followers(notifiable, _action = nil)

# Kicks off a worker to send any notifications about the post being published, if necessary.
Notification.send_to_followers(notifiable, "Published")

# Credited co-authors are notified separately from followers.
Notification.send_to_co_authors(notifiable)
end

def send_to_co_authors(notifiable, removed_user_ids = [])
return unless notifiable.is_a?(Article)
return if notifiable.co_author_ids.blank? && removed_user_ids.blank?

Notifications::CoAuthorWorker.perform_async(notifiable.id, removed_user_ids)
end

def send_to_followers(notifiable, action = nil)
Expand Down
2 changes: 2 additions & 0 deletions app/services/articles/updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def send_to_mentioned_users_and_followers
def remove_all_notifications
Notification.remove_all_by_action_without_delay(notifiable_ids: article.id, notifiable_type: "Article",
action: "Published")
Notification.remove_all_by_action_without_delay(notifiable_ids: article.id, notifiable_type: "Article",
action: "CoAuthor")
ContextNotification.delete_by(context_id: article.id, context_type: "Article",
action: "Published")

Expand Down
3 changes: 3 additions & 0 deletions app/services/moderator/unpublish_all_articles.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ def clean_up_notifications(article)
Notification.remove_all_by_action_without_delay(notifiable_ids: article.id,
notifiable_type: "Article",
action: "Published")
Notification.remove_all_by_action_without_delay(notifiable_ids: article.id,
notifiable_type: "Article",
action: "CoAuthor")

ContextNotification.delete_by(context_id: article.id, context_type: "Article", action: "Published")

Expand Down
32 changes: 32 additions & 0 deletions app/services/notifications/co_author/remove.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Removes co-author notifications for users who are no longer credited.
module Notifications
module CoAuthor
class Remove
def self.call(...)
new(...).call
end

# @param article_id [Integer]
# @param user_ids [Array<Integer>]
def initialize(article_id, user_ids)
@article_id = article_id
@user_ids = Array.wrap(user_ids).map(&:to_i)
end

def call
return if user_ids.empty?

Notification.where(
user_id: user_ids,
notifiable_id: article_id,
notifiable_type: "Article",
action: Notifications::CoAuthor::Send::ACTION,
).delete_all
end

private

attr_reader :article_id, :user_ids
end
end
end
76 changes: 76 additions & 0 deletions app/services/notifications/co_author/send.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Notifies co-authors that a post they are credited on is live.
module Notifications
module CoAuthor
class Send
ACTION = "CoAuthor".freeze

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

# @param article [Article]
def initialize(article)
@article = article
end

delegate :user_data, :article_data, :organization_data, to: Notifications

def call
return unless article.is_a?(Article)
return unless article.published? && !article.scheduled? && article.type_of == "full_post"

recipient_ids = Array.wrap(article.co_author_ids).map(&:to_i) - [article.user_id]
return if recipient_ids.empty?

# Skip anyone already notified so re-publishing or an unrelated edit
# does not notify the same co-author twice.
already_notified = Notification.where(
user_id: recipient_ids,
notifiable_id: article.id,
notifiable_type: "Article",
action: ACTION,
).pluck(:user_id)

pending_ids = recipient_ids - already_notified
return if pending_ids.empty?

now = Time.current
attributes = User.where(id: pending_ids).ids.map do |user_id|
{
user_id: user_id,
notifiable_id: article.id,
notifiable_type: "Article",
subforem_id: article.subforem_id,
action: ACTION,
json_data: json_data,
created_at: now,
notified_at: now,
updated_at: now
}
end
return if attributes.empty?

# upsert rather than insert: a unique index covers
# (user_id, notifiable_id, notifiable_type, action), and two publishes
# racing would otherwise raise instead of no-opping.
Notification.upsert_all(
attributes,
unique_by: :index_notifications_on_user_notifiable_and_action_not_null,
)
end

private

attr_reader :article

def json_data
data = {
user: user_data(article.user),
article: article_data(article)
}
data[:organization] = organization_data(article.organization) if article.organization_id
data
end
end
end
end
6 changes: 5 additions & 1 deletion app/services/notifications/notifiable_action/send.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ def call
# have a mention in order to avoid sending a user multiple notifications for one article.
user_ids_with_article_mentions = notifiable.mentions&.pluck(:user_id)

# Co-authors receive their own notification, so exclude them here for the
# same reason mentions are excluded: one notification per person per post.
co_author_ids = Array.wrap(notifiable.co_author_ids).map(&:to_i)

article_followers = User.joins("INNER JOIN follows ON follows.follower_id = users.id")
.where("(follows.followable_id = ? AND follows.followable_type = ?)
OR (follows.followable_id = ? AND follows.followable_type = ?)",
notifiable&.user&.id, "User", notifiable&.organization&.id, "Organization")
.where(follows: { subscription_status: "all_articles" })
.where.not(id: (user_ids_with_article_mentions + [notifiable.user]))
.where.not(id: (user_ids_with_article_mentions + co_author_ids + [notifiable.user]))
.recently_active(FOLLOWER_SEND_LIMIT).distinct

article_followers.find_each do |follower|
Expand Down
9 changes: 9 additions & 0 deletions app/views/notifications/_article.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words json_data["article"]["published_at"], scope: :"datetime.distance_in_words_ago" %></small></p>
</header>

<%= render "notifications/shared/article_preview", notification: notification, context: "default" %>
<% elsif notification.action == "CoAuthor" %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= message_user_acted_maybe_org(json_data, "views.notifications.co_author.verb_html", if_org: "views.notifications.co_author.if_org_html") %>
</h2>
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words json_data["article"]["published_at"], scope: :"datetime.distance_in_words_ago" %></small></p>
</header>

<%= render "notifications/shared/article_preview", notification: notification, context: "default" %>
<% elsif notification.action == "Moderation" %>
<% new_user = mod_article_user(json_data) %>
Expand Down
16 changes: 16 additions & 0 deletions app/workers/notifications/co_author_worker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module Notifications
class CoAuthorWorker
include Sidekiq::Job

sidekiq_options queue: :low_priority, lock: :until_executing, on_conflict: :replace, retry: 10

def perform(article_id, removed_user_ids = [])
Notifications::CoAuthor::Remove.call(article_id, removed_user_ids)

article = Article.find_by(id: article_id)
return unless article

Notifications::CoAuthor::Send.call(article)
end
end
end
9 changes: 9 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

# Better Stack's Rails integration has to load before the app boots; only load it when configured
# (see config/initializers/betterstack.rb).
require "logtail-rails" if Rails.env.production? && ENV["BETTERSTACK_SOURCE_TOKEN"].present?
# Same for the Better Stack errors pilot (see config/initializers/sentry.rb).
if Rails.env.production? && ENV["BETTER_STACK_ERRORS_DSN"].present?
require "sentry-rails"
require "sentry-sidekiq"
end

if defined?(Anyway)
Anyway.loaders.delete(:secrets)
end
Expand Down
Loading
Loading