This commit is contained in:
Emin Kocan 2025-03-12 17:07:51 +00:00 committed by GitHub
commit 2cb0b324e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 306 additions and 0 deletions

194
lib/user_activities.rb Normal file
View file

@ -0,0 +1,194 @@
# rubocop:disable Metrics/ModuleLength
module UserActivities
def self.for_user(user_id, limit: 5, offset: 0)
ActiveRecord::Base.connection.execute(activity_sql(user_id, :limit => limit, :offset => offset)).to_a
end
def self.count_activities(user_id)
ActiveRecord::Base.connection.execute(count_sql(user_id)).first["count"]
end
private_class_method def self.count_sql(user_id)
<<~SQL.squish
WITH activities AS (
#{base_activities_sql(user_id)}
)
SELECT COUNT(DISTINCT DATE_TRUNC('day', timestamp)) as count
FROM activities
SQL
end
private_class_method def self.activity_sql(user_id, limit:, offset:)
<<~SQL.squish
WITH activities AS (
#{base_activities_sql(user_id)}
),
activity_days AS (
SELECT DISTINCT DATE_TRUNC('day', timestamp) as day
FROM activities
ORDER BY day DESC
LIMIT #{limit}
OFFSET #{offset}
),
activities_by_day AS (
SELECT
DATE_TRUNC('day', a.timestamp) AS activity_date,
a.category,
a.activity_type,
COUNT(*) as count,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', a.activity_id,
'reference_id', a.reference_id,
'additional_reference_id', a.additional_reference_id,
'description', a.description,
'source_type', a.source_type,
'user_display_name', a.user_display_name,
'timestamp', a.timestamp
)
ORDER BY a.timestamp DESC
) as items
FROM activities a
INNER JOIN activity_days d ON DATE_TRUNC('day', a.timestamp) = d.day
GROUP BY activity_date, a.category, a.activity_type
),
grouped_activities AS (
SELECT
activity_date,
JSONB_OBJECT_AGG(
CONCAT(category, ':', activity_type),
JSONB_BUILD_OBJECT(
'count', count,
'items', items
)
) as activity_groups
FROM activities_by_day
GROUP BY activity_date
ORDER BY activity_date DESC
)
SELECT * FROM grouped_activities
SQL
end
# Extract common activity types
private_class_method def self.base_activities_sql(user_id)
quoted_user_id = ActiveRecord::Base.connection.quote(user_id)
[
changeset_sql(quoted_user_id),
diary_entry_sql(quoted_user_id),
changeset_comment_sql(quoted_user_id),
note_comment_sql(quoted_user_id),
diary_comment_sql(quoted_user_id),
gpx_file_sql(quoted_user_id)
].join(" UNION ALL ")
end
private_class_method def self.changeset_sql(quoted_user_id)
<<~SQL.squish
SELECT
c.created_at AS timestamp,
CAST('changeset' AS text) AS category,
CAST('opened' AS text) AS activity_type,
c.id AS activity_id,
c.id AS reference_id,
NULL AS additional_reference_id,
NULL AS description,
'changeset' AS source_type,
NULL AS user_display_name
FROM changesets c
WHERE c.user_id = #{quoted_user_id}
SQL
end
private_class_method def self.diary_entry_sql(quoted_user_id)
<<~SQL.squish
SELECT
d.created_at AS timestamp,
CAST('diary' AS text) AS category,
CAST('diary_entry' AS text) AS activity_type,
d.id AS activity_id,
d.id AS reference_id,
d.title AS additional_reference_id,
d.body AS description,
'diary' AS source_type,
u.display_name AS user_display_name
FROM diary_entries d
JOIN users u ON u.id = #{quoted_user_id}
WHERE d.user_id = #{quoted_user_id}
AND d.visible = true
SQL
end
private_class_method def self.changeset_comment_sql(quoted_user_id)
<<~SQL.squish
SELECT
cc.created_at,
CAST('comment' AS text) AS category,
CAST('comment' AS text) AS activity_type,
cc.id,
cc.changeset_id AS reference_id,
NULL AS additional_reference_id,
cc.body,
'changeset' AS source_type,
NULL AS user_display_name
FROM changeset_comments cc
WHERE cc.author_id = #{quoted_user_id}
SQL
end
private_class_method def self.note_comment_sql(quoted_user_id)
<<~SQL.squish
SELECT
nc.created_at,
CAST('comment' AS text) AS category,
CAST('comment' AS text) AS activity_type,
nc.id,
nc.note_id AS reference_id,
NULL AS additional_reference_id,
nc.body,
'note' AS source_type,
NULL AS user_display_name
FROM note_comments nc
WHERE nc.author_id = #{quoted_user_id}
SQL
end
private_class_method def self.diary_comment_sql(quoted_user_id)
<<~SQL.squish
SELECT
dc.created_at,
CAST('comment' AS text) AS category,
CAST('comment' AS text) AS activity_type,
dc.id,
dc.diary_entry_id AS reference_id,
d.title AS additional_reference_id,
dc.body,
'diary' AS source_type,
u.display_name AS user_display_name
FROM diary_comments dc
JOIN diary_entries d ON d.id = dc.diary_entry_id
JOIN users u ON u.id = #{quoted_user_id}
WHERE dc.user_id = #{quoted_user_id}
AND dc.visible = true
SQL
end
private_class_method def self.gpx_file_sql(quoted_user_id)
<<~SQL.squish
SELECT
g.timestamp AS timestamp,
CAST('gpx' AS text) AS category,
CAST('upload' AS text) AS activity_type,
g.id AS activity_id,
g.id AS reference_id,
g.name AS additional_reference_id,
g.description AS description,
'gpx' AS source_type,
u.display_name AS user_display_name
FROM gpx_files g
JOIN users u ON u.id = #{quoted_user_id}
WHERE g.user_id = #{quoted_user_id}
SQL
end
end
# rubocop:enable Metrics/ModuleLength

View file

@ -0,0 +1,112 @@
require "test_helper"
class UserActivitiesTest < ActiveSupport::TestCase
def setup
@user = create(:user)
# Create a language for diary entries
Language.create!(:code => "en", :english_name => "English")
end
def test_for_user_returns_activities_grouped_by_day
# Create activities on different days
create(:changeset, :user => @user, :created_at => 2.days.ago)
create(:diary_entry, :user => @user, :created_at => 2.days.ago)
create(:changeset, :user => @user, :created_at => 1.day.ago)
create(:note_comment, :author => @user, :created_at => 1.day.ago)
activities = UserActivities.for_user(@user.id)
assert_equal 2, activities.length # Two days of activities
# First day should have activities
first_day = activities[0]
assert first_day["activity_date"]
assert first_day["activity_groups"]
# Second day should have activities
second_day = activities[1]
assert second_day["activity_date"]
assert second_day["activity_groups"]
# Check that we have the right number of activities per day
first_day_count = JSON.parse(first_day["activity_groups"]).values.sum { |g| g["count"] }
second_day_count = JSON.parse(second_day["activity_groups"]).values.sum { |g| g["count"] }
assert_equal 2, first_day_count
assert_equal 2, second_day_count
end
def test_for_user_respects_limit_and_offset
# Create activities across three days
create(:changeset, :user => @user, :created_at => 3.days.ago)
create(:diary_entry, :user => @user, :created_at => 2.days.ago)
create(:note_comment, :author => @user, :created_at => 1.day.ago)
# Get first page
activities = UserActivities.for_user(@user.id, :limit => 2, :offset => 0)
assert_equal 2, activities.length
# Get second page
activities = UserActivities.for_user(@user.id, :limit => 2, :offset => 2)
assert_equal 1, activities.length
end
def test_count_activities_returns_number_of_active_days
create(:changeset, :user => @user, :created_at => 2.days.ago)
create(:diary_entry, :user => @user, :created_at => 2.days.ago) # Same day
create(:note_comment, :author => @user, :created_at => 1.day.ago) # Different day
count = UserActivities.count_activities(@user.id)
assert_equal 2, count # Should count two distinct days
end
def test_activities_include_all_types
# Create one of each activity type
changeset = create(:changeset, :user => @user)
diary = create(:diary_entry, :user => @user)
note = create(:note_comment, :author => @user)
gpx = create(:trace, :user => @user)
diary_comment = create(:diary_comment, :user => @user)
activities = UserActivities.for_user(@user.id)
# Extract all items from all activity groups
all_items = []
activities.each do |day|
groups = JSON.parse(day["activity_groups"])
groups.each_value do |group|
items = group["items"].is_a?(String) ? JSON.parse(group["items"]) : group["items"]
all_items.concat(items)
end
end
# Check that each activity type is present
assert(all_items.any? { |i| i["reference_id"] == changeset.id })
assert(all_items.any? { |i| i["reference_id"] == diary.id })
assert(all_items.any? { |i| i["reference_id"] == note.note_id })
assert(all_items.any? { |i| i["reference_id"] == gpx.id })
assert(all_items.any? { |i| i["reference_id"] == diary_comment.diary_entry_id })
end
def test_activities_respect_visibility
# Create visible and invisible items
create(:diary_entry, :user => @user, :visible => true)
create(:diary_entry, :user => @user, :visible => false)
create(:diary_comment, :user => @user, :visible => true)
create(:diary_comment, :user => @user, :visible => false)
activities = UserActivities.for_user(@user.id)
# Count all items from all activity groups
all_items = []
activities.each do |day|
groups = JSON.parse(day["activity_groups"])
groups.each_value do |group|
items = group["items"].is_a?(String) ? JSON.parse(group["items"]) : group["items"]
all_items.concat(items)
end
end
# Should only include visible items
assert_equal 2, all_items.length
end
end