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
3 changes: 3 additions & 0 deletions app/controllers/ahoy/email_clicks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ def create
AhoyEmail::Utils.publish(:click, data)
track_billboard if params[:bb].present?
record_feed_event if @url.present?

EmailMessage.find_by(token: @token)&.user&.update_presence!

head :ok # Renders a blank response with a 200 OK status
end

Expand Down
1 change: 1 addition & 0 deletions app/controllers/async_info_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class AsyncInfoController < ApplicationController
def base_data
flash.discard(:notice)
if user_signed_in? && verify_state_of_user_session?
current_user.update_presence!
@user = current_user.decorate
respond_to do |format|
format.json do
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/stories/tagged_articles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def set_stories(number_of_articles:, page:, tag:)

# Now, apply the filter.
stories = stories_by_timeframe(stories: stories)
stories = stories.full_posts.from_subforem.limited_column_select
stories = stories.full_posts.from_subforem
@stories = stories.decorate
end

Expand Down
11 changes: 11 additions & 0 deletions app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,17 @@ def self.unique_url_error
:last_comment_at, :main_image_height, :type_of, :edited_at, :processed_html, :subforem_id)
}

scope :minimal_feed_column_select, lambda {
select(:path, :title, :id, :published,
:comments_count, :public_reactions_count, :cached_tag_list,
:main_image, :main_image_background_hex_color, :updated_at, :slug,
:video, :user_id, :organization_id, :video_source_url, :video_code,
:video_thumbnail_url, :video_closed_caption_track_url,
:experience_level_rating, :experience_level_rating_distribution, :cached_user, :cached_organization,
:published_at, :crossposted_at, :description, :reading_time, :video_duration_in_seconds, :score,
:last_comment_at, :main_image_height, :type_of, :edited_at, :subforem_id)
}

scope :limited_columns_internal_select, lambda {
select(:path, :title, :id, :featured, :approved, :published,
:comments_count, :public_reactions_count, :cached_tag_list,
Expand Down
6 changes: 6 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,12 @@ def member_bot?
def bot?
community_bot? || member_bot?
end

def update_presence!
return if last_presence_at.present? && last_presence_at > 1.hour.ago

update_column(:last_presence_at, Time.current)
end

protected

Expand Down
19 changes: 2 additions & 17 deletions app/services/ai/email_digest_summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,34 +27,19 @@ def cache_key
def prompt
base_url = "https://#{Settings::General.app_domain}"
article_list = @articles.map do |a|
content = "- Title: #{a.title}\n URL: #{base_url}#{a.path}\n Description: #{a.description}\n Tags: #{a.cached_tag_list}"

if a.respond_to?(:comments_count) && a.comments_count > 15
top_comments = Comment.where(commentable_id: a.id, commentable_type: "Article")
.where(hidden_by_commentable_user: false, deleted: false)
.order(score: :desc)
.limit(5)
.pluck(:body_markdown)
if top_comments.any?
content += "\n Top Comments (use for extra context if relevant):\n - " + top_comments.map do |c|
c.tr("\n", " ").truncate(150)
end.join("\n - ")
end
end
content
"- Title: #{a.title}\n URL: #{base_url}#{a.path}\n Description: #{a.description}\n Tags: #{a.cached_tag_list}"
end.join("\n\n")

<<~PROMPT
You are an insightful technical curator for the DEV community.
I will provide you with a list of articles from a developer community digest, sometimes including top comments for articles with high engagement.
I will provide you with a list of articles from a developer community digest.

Your task is to write a brief, engaging "Digest Overview" (about 2 short paragraphs) that:
1. Identifies and describes the most interesting thematic threads in this collection.
2. Synthesizes how these articles connect or relate to broader trends.

Guidelines for Selection & Synthesis:
- You do NOT need to mention every article. Focus on the ones that make the most sense together to draw out compelling themes.
- Use the provided "Top Comments" as additional context if they are contextually relevant to the theme or provide interesting community perspective alongside the article content.

Guidelines for Formatting:
- Keep it very concise and not overly wordy.
Expand Down
2 changes: 1 addition & 1 deletion app/services/articles/feeds/large_forem_experimental.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def globally_hot_articles(user_signed_in, must_have_main_image: true, article_sc
hot_stories = hot_stories.to_a + new_stories.to_a
else
hot_stories = Article.published.from_subforem.limited_column_select
.includes(:distinct_reaction_categories, :subforem)
.includes(:distinct_reaction_categories, :subforem, top_comments: :user)
.page(@page).per(@number_of_articles)
.with_at_least_home_feed_minimum_score
.order(hotness_score: :desc)
Expand Down
3 changes: 1 addition & 2 deletions app/services/articles/feeds/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ def self.call(tag = nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_WI

articles
.published
.limited_column_select
.includes(top_comments: :user)
.minimal_feed_column_select
.includes(:distinct_reaction_categories, :context_notes)
.page(page)
.per(number_of_articles)
Expand Down
2 changes: 1 addition & 1 deletion app/services/articles/feeds/variant_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def call(only_featured: false, must_have_main_image: false, limit: default_limit
# goodness of scopes (e.g., limited_column_select) and eager includes.
scope = Article.joins(join_fragment)
.from_subforem
.limited_column_select
.minimal_feed_column_select
.includes(:distinct_reaction_categories, :context_notes)
.includes(:subforem)
.order(config.order_by.to_sql)
Expand Down
2 changes: 1 addition & 1 deletion app/services/articles/get_user_stickies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def self.call(article, author)
.published.from_subforem
.cached_tagged_with_any(article_tags)
.unscope(:select)
.limited_column_select
.minimal_feed_column_select
.where.not(id: article.id)
.order(published_at: :desc)
.limit(3)
Expand Down
2 changes: 1 addition & 1 deletion app/services/articles/suggest_stickies.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def apply_common_scope(scope:, tags:)
scope.published.from_subforem
.cached_tagged_with_any(tags)
.unscope(:select)
.limited_column_select
.minimal_feed_column_select
.where.not(id: article.id)
.not_authored_by(article.user_id)
.where("published_at > ?", 5.days.ago)
Expand Down
3 changes: 1 addition & 2 deletions app/workers/emails/send_user_digest_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ def perform(user_id, force_send = false)
user_signed_in: true)

begin
user_ids_for_ai = ENV["AI_DIGEST_SUMMARY_USER_IDS"]&.split(",")&.map(&:to_i) || []
smart_summary = if user_ids_for_ai.include?(user.id)
smart_summary = if user.last_presence_at.present? && user.last_presence_at >= 3.days.ago
Ai::EmailDigestSummary.new(articles.to_a).generate
end

Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260127141307_add_last_presence_at_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddLastPresenceAtToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :last_presence_at, :datetime
end
end
23 changes: 22 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2025_12_30_120202) do
ActiveRecord::Schema[7.0].define(version: 2026_01_27_141307) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "ltree"
Expand Down Expand Up @@ -447,9 +447,11 @@
t.datetime "created_at", null: false
t.text "processed_html", null: false
t.bigint "tag_id"
t.bigint "trend_id"
t.datetime "updated_at", null: false
t.index ["article_id"], name: "index_context_notes_on_article_id"
t.index ["tag_id"], name: "index_context_notes_on_tag_id"
t.index ["trend_id"], name: "index_context_notes_on_trend_id"
end

create_table "context_notifications", force: :cascade do |t|
Expand Down Expand Up @@ -826,6 +828,7 @@

create_table "navigation_links", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.boolean "display_only_when_signed_in", default: false
t.integer "display_to", default: 0, null: false
t.string "icon"
Expand Down Expand Up @@ -1404,10 +1407,14 @@
end

create_table "tag_subforem_relationships", force: :cascade do |t|
t.string "bg_color_hex"
t.datetime "created_at", null: false
t.string "pretty_name"
t.text "short_summary"
t.bigint "subforem_id", null: false
t.boolean "supported", default: true
t.bigint "tag_id", null: false
t.string "text_color_hex"
t.datetime "updated_at", null: false
t.index ["subforem_id"], name: "index_tag_subforem_relationships_on_subforem_id"
t.index ["tag_id"], name: "index_tag_subforem_relationships_on_tag_id"
Expand Down Expand Up @@ -1465,6 +1472,18 @@
t.index ["taggings_count"], name: "index_tags_on_taggings_count"
end

create_table "trends", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "expiry_date", null: false
t.text "full_content_description", null: false
t.text "public_description", null: false
t.string "short_title", null: false
t.bigint "subforem_id", null: false
t.datetime "updated_at", null: false
t.index ["expiry_date"], name: "index_trends_on_expiry_date"
t.index ["subforem_id"], name: "index_trends_on_subforem_id"
end

create_table "tweets", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.text "extended_entities_serialized", default: "--- {}\n"
Expand Down Expand Up @@ -1624,6 +1643,7 @@
t.datetime "last_moderation_notification", precision: nil, default: "2017-01-01 05:00:00"
t.datetime "last_notification_activity", precision: nil
t.string "last_onboarding_page"
t.datetime "last_presence_at"
t.datetime "last_reacted_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.inet "last_sign_in_ip"
Expand Down Expand Up @@ -1853,6 +1873,7 @@
add_foreign_key "tag_subforem_relationships", "tags"
add_foreign_key "taggings", "tags", on_delete: :cascade
add_foreign_key "tags", "badges", on_delete: :nullify
add_foreign_key "trends", "subforems"
add_foreign_key "tweets", "users", on_delete: :nullify
add_foreign_key "user_activities", "users"
add_foreign_key "user_blocks", "users", column: "blocked_id"
Expand Down
23 changes: 23 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,29 @@ def provider_username(service_name)
end
end

describe "#update_presence!" do
context "when last_presence_at is nil" do
it "updates last_presence_at to current time" do
user.update_column(:last_presence_at, nil)
expect { user.update_presence! }.to change(user, :last_presence_at)
end
end

context "when last_presence_at is more than 1 hour ago" do
it "updates last_presence_at to current time" do
user.update_column(:last_presence_at, 2.hours.ago)
expect { user.update_presence! }.to change(user, :last_presence_at)
end
end

context "when last_presence_at is less than 1 hour ago" do
it "does not update last_presence_at" do
user.update_column(:last_presence_at, 30.minutes.ago)
expect { user.update_presence! }.not_to change(user, :last_presence_at)
end
end
end

describe "#receives_follower_email_notifications?" do
it "returns false if user has no email" do
user.assign_attributes(email: nil)
Expand Down
8 changes: 8 additions & 0 deletions spec/requests/ahoy/email_clicks_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
hash_including(token: token, campaign: campaign, url: url, controller: controller))
expect(FeedEvent.where(article_id: article.id, category: "click", context_type: "email").size).to be(1)
end

it "updates the user's presence" do
user = create(:user, last_presence_at: 2.hours.ago)
create(:email_message, user: user, token: token)

expect { post ahoy_email_clicks_path, params: { t: token, c: campaign, u: url, s: signature } }
.to change { user.reload.last_presence_at }
end
end

context "with an invalid signature" do
Expand Down
7 changes: 7 additions & 0 deletions spec/requests/async_info_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
get "/async_info/base_data"
expect(response.parsed_body["default_email_optin_allowed"]).to be(true)
end

it "updates the user's presence" do
user = create(:user, last_presence_at: 2.hours.ago)
sign_in user

expect { get "/async_info/base_data" }.to change { user.reload.last_presence_at }
end
end
end

Expand Down
19 changes: 0 additions & 19 deletions spec/services/ai/email_digest_summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,24 +70,5 @@

expect(service.generate).to be_nil
end

context "when an article has many comments" do
let(:article_with_comments) do
instance_double(Article, id: 3, title: "Highly Discussed", path: "/path3", description: "Desc 3",
cached_tag_list: "discuss", comments_count: 20)
end
let(:articles) { [article_with_comments] }

it "includes top comments in the prompt" do
allow(Comment).to receive_message_chain(:where, :where, :order, :limit, :pluck)
.and_return(["Comment 1", "Comment 2"])

service.generate

expect(ai_client).to have_received(:call).with(/Top Comments \(use for extra context if relevant\):/)
expect(ai_client).to have_received(:call).with(/Comment 1/)
expect(ai_client).to have_received(:call).with(/Comment 2/)
end
end
end
end
19 changes: 13 additions & 6 deletions spec/workers/emails/send_user_digest_worker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,26 @@
allow(smart_summary_service).to receive(:generate).and_return("Smart AI Summary")
end

it "generates and includes smart summary if user is in experiment list" do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("AI_DIGEST_SUMMARY_USER_IDS").and_return(user.id.to_s)
it "generates and includes smart summary if user has recent presence" do
user.update_column(:last_presence_at, 1.day.ago)
create_list(:article, 3, user_id: author.id, public_reactions_count: 20, score: 20, tag_list: [tag.name])

worker.perform(user.id)

expect(DigestMailer).to have_received(:with).with(hash_including(smart_summary: "Smart AI Summary"))
end

it "does not include smart summary if user is not in experiment list" do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("AI_DIGEST_SUMMARY_USER_IDS").and_return("99999")
it "does not include smart summary if user has no recent presence" do
user.update_column(:last_presence_at, 4.days.ago)
create_list(:article, 3, user_id: author.id, public_reactions_count: 20, score: 20, tag_list: [tag.name])

worker.perform(user.id)

expect(DigestMailer).to have_received(:with).with(hash_including(smart_summary: nil))
end

it "does not include smart summary if user presence is nil" do
user.update_column(:last_presence_at, nil)
create_list(:article, 3, user_id: author.id, public_reactions_count: 20, score: 20, tag_list: [tag.name])

worker.perform(user.id)
Expand Down
Loading