diff --git a/app/services/discourse_rewind/action/ai_usage.rb b/app/services/discourse_rewind/action/ai_usage.rb new file mode 100644 index 0000000..3e4880c --- /dev/null +++ b/app/services/discourse_rewind/action/ai_usage.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# AI usage statistics from discourse-ai plugin +# Shows total usage, favorite features, token consumption, etc. +module DiscourseRewind + module Action + class AiUsage < BaseReport + def call + return if !enabled? + + base_query = AiApiAuditLog.where(user_id: user.id).where(created_at: date) + + # Get aggregated stats in a single query + stats = + base_query.select( + "COUNT(*) as total_requests", + "COALESCE(SUM(request_tokens), 0) as total_request_tokens", + "COALESCE(SUM(response_tokens), 0) as total_response_tokens", + "COUNT(CASE WHEN response_tokens > 0 THEN 1 END) as successful_requests", + ).take + + return if stats.total_requests == 0 + + total_tokens = stats.total_request_tokens + stats.total_response_tokens + success_rate = + ( + if stats.total_requests > 0 + (stats.successful_requests.to_f / stats.total_requests * 100).round(1) + else + 0 + end + ) + + # Most used features (top 5) + feature_usage = + base_query + .group(:feature_name) + .order("COUNT(*) DESC") + .limit(5) + .pluck(:feature_name, Arel.sql("COUNT(*)")) + .to_h + + # Most used AI model (top 5) + model_usage = + base_query + .where.not(language_model: nil) + .group(:language_model) + .order("COUNT(*) DESC") + .limit(5) + .pluck(:language_model, Arel.sql("COUNT(*)")) + .to_h + + { + data: { + total_requests: stats.total_requests, + total_tokens: total_tokens, + request_tokens: stats.total_request_tokens, + response_tokens: stats.total_response_tokens, + feature_usage: feature_usage, + model_usage: model_usage, + success_rate: success_rate, + }, + identifier: "ai-usage", + } + end + + def enabled? + defined?(AiApiAuditLog) && SiteSetting.discourse_ai_enabled + end + end + end +end diff --git a/app/services/discourse_rewind/action/assignments.rb b/app/services/discourse_rewind/action/assignments.rb new file mode 100644 index 0000000..f8e1975 --- /dev/null +++ b/app/services/discourse_rewind/action/assignments.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Assignment statistics using discourse-assign plugin data +# Shows how many assignments, completed, pending, etc. +module DiscourseRewind + module Action + class Assignments < BaseReport + def call + return if !enabled? + + # Assignments made to the user + assignments_scope = + Assignment + .where(assigned_to_id: user.id, assigned_to_type: "User") + .where(created_at: date) + + total_assigned = assignments_scope.count + + # Completed assignments (topics that were assigned and then closed or unassigned) + completed_count = + assignments_scope + .joins(:topic) + .where( + "topics.closed = true OR assignments.active = false OR assignments.updated_at > assignments.created_at", + ) + .distinct + .count + + # Currently pending (still open and assigned) + pending_count = + Assignment + .where(assigned_to_id: user.id, assigned_to_type: "User", active: true) + .joins(:topic) + .where(topics: { closed: false }) + .count + + # Assignments made by the user to others + assigned_by_user = + Assignment + .where(assigned_by_user_id: user.id) + .where(created_at: date) + .count + + { + data: { + total_assigned: total_assigned, + completed: completed_count, + pending: pending_count, + assigned_by_user: assigned_by_user, + completion_rate: + total_assigned > 0 ? (completed_count.to_f / total_assigned * 100).round(1) : 0, + }, + identifier: "assignments", + } + end + + def enabled? + defined?(Assignment) && SiteSetting.assign_enabled + end + end + end +end diff --git a/app/services/discourse_rewind/action/chat_usage.rb b/app/services/discourse_rewind/action/chat_usage.rb new file mode 100644 index 0000000..a4fee7b --- /dev/null +++ b/app/services/discourse_rewind/action/chat_usage.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Chat usage statistics +# Shows message counts, favorite channels, DM activity, etc. +module DiscourseRewind + module Action + class ChatUsage < BaseReport + def call + return if !enabled? + + messages = + Chat::Message.where(user_id: user.id).where(created_at: date).where(deleted_at: nil) + + total_messages = messages.count + return if total_messages == 0 + + # Get favorite channels (public channels) + channel_usage = + messages + .joins(:chat_channel) + .where(chat_channels: { type: "CategoryChannel" }) + .group("chat_channels.id", "chat_channels.name") + .count + .sort_by { |_, count| -count } + .first(5) + .map do |(id, name), count| + { channel_id: id, channel_name: name, message_count: count } + end + + # DM statistics + dm_message_count = + messages.joins(:chat_channel).where(chat_channels: { type: "DirectMessageChannel" }).count + + # Unique DM conversations + unique_dm_channels = + messages + .joins(:chat_channel) + .where(chat_channels: { type: "DirectMessageChannel" }) + .distinct + .count(:chat_channel_id) + + # Messages with reactions received + messages_with_reactions = + Chat::MessageReaction + .joins(:chat_message) + .where(chat_messages: { user_id: user.id }) + .where(chat_messages: { created_at: date }) + .distinct + .count(:chat_message_id) + + # Total reactions received + total_reactions_received = + Chat::MessageReaction + .joins(:chat_message) + .where(chat_messages: { user_id: user.id }) + .where(chat_messages: { created_at: date }) + .count + + # Average message length + avg_message_length = + messages.where("LENGTH(message) > 0").average("LENGTH(message)")&.to_f&.round(1) || 0 + + { + data: { + total_messages: total_messages, + favorite_channels: channel_usage, + dm_message_count: dm_message_count, + unique_dm_channels: unique_dm_channels, + messages_with_reactions: messages_with_reactions, + total_reactions_received: total_reactions_received, + avg_message_length: avg_message_length, + }, + identifier: "chat-usage", + } + end + + def enabled? + SiteSetting.chat_enabled + end + end + end +end diff --git a/app/services/discourse_rewind/action/favorite_gifs.rb b/app/services/discourse_rewind/action/favorite_gifs.rb new file mode 100644 index 0000000..ab093e9 --- /dev/null +++ b/app/services/discourse_rewind/action/favorite_gifs.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +# Find the user's most used GIFs in posts and chat +# Ranks by usage count and engagement (likes/reactions) +module DiscourseRewind + module Action + class FavoriteGifs < BaseReport + GIF_URL_PATTERN = + %r{ + https?://[^\s]+\.(?:gif|gifv) + | + https?://(?!(?:developers|support|blog)\.) (?:[^/\s]+\.)?giphy\.com/(?!dashboard\b)[^\s]+ + | + https?://(?!(?:support)\.) (?:[^/\s]+\.)?tenor\.com/(?!gifapi\b)[^\s]+ + }ix + MAX_RESULTS = 5 + + def call + gif_data = {} + + # Get GIFs from posts + post_gifs = extract_gifs_from_posts + post_gifs.each do |url, data| + gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 } + gif_data[url][:usage_count] += data[:count] + gif_data[url][:likes] += data[:likes] + end + + # Get GIFs from chat messages if chat is enabled + if SiteSetting.chat_enabled + chat_gifs = extract_gifs_from_chat + chat_gifs.each do |url, data| + gif_data[url] ||= { url: url, usage_count: 0, likes: 0, reactions: 0 } + gif_data[url][:usage_count] += data[:count] + gif_data[url][:reactions] += data[:reactions] + end + end + + return if gif_data.empty? + + # Sort by engagement score (usage * 10 + likes + reactions) + sorted_gifs = + gif_data + .values + .sort_by { |gif| -(gif[:usage_count] * 10 + gif[:likes] + gif[:reactions]) } + .first(MAX_RESULTS) + + { + data: { + favorite_gifs: sorted_gifs, + total_gif_usage: gif_data.values.sum { |g| g[:usage_count] }, + }, + identifier: "favorite-gifs", + } + end + + private + + def extract_gifs_from_posts + gif_usage = {} + + posts = + Post + .where(user_id: user.id) + .where(created_at: date) + .where(deleted_at: nil) + .where("raw ~* ?", gif_sql_pattern) + .select(:id, :raw, :like_count) + + posts.each do |post| + gif_urls = post.raw.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) } + gif_urls.each do |url| + gif_usage[url] ||= { count: 0, likes: 0 } + gif_usage[url][:count] += 1 + gif_usage[url][:likes] += post.like_count || 0 + end + end + + gif_usage + end + + def extract_gifs_from_chat + gif_usage = {} + + messages = + Chat::Message + .where(user_id: user.id) + .where(created_at: date) + .where(deleted_at: nil) + .where("message ~* ?", gif_sql_pattern) + .select(:id, :message) + + messages.each do |message| + gif_urls = + message.message.scan(GIF_URL_PATTERN).uniq.select { |url| content_gif_url?(url) } + gif_urls.each do |url| + gif_usage[url] ||= { count: 0, reactions: 0 } + gif_usage[url][:count] += 1 + + # Count reactions on this message + reaction_count = Chat::MessageReaction.where(chat_message_id: message.id).count + gif_usage[url][:reactions] += reaction_count + end + end + + gif_usage + end + + def gif_sql_pattern + @gif_sql_pattern ||= GIF_URL_PATTERN.source.gsub(/\s+/, "") + end + + def content_gif_url?(url) + return true if url.match?(/\.(gif|gifv)(?:\?|$)/i) + return true if url.match?(%r{giphy\.com/(?:gifs?|media|embed|stickers|clips)}i) + return true if url.match?(%r{tenor\.com/(?:view|watch|embed|gif)}i) + + false + end + end + end +end diff --git a/app/services/discourse_rewind/action/invites.rb b/app/services/discourse_rewind/action/invites.rb new file mode 100644 index 0000000..dc2feae --- /dev/null +++ b/app/services/discourse_rewind/action/invites.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Invite statistics +# Shows how many users this user invited and the impact of those invitees +module DiscourseRewind + module Action + class Invites < BaseReport + def call + # Get all invites created by this user in the date range + invites = Invite.where(invited_by_id: user.id).where(created_at: date) + + total_invites = invites.count + return if total_invites == 0 + + # Redeemed invites (users who actually joined) + redeemed_count = invites.where.not(redeemed_at: nil).count + + # Get the users who were invited (via InvitedUser or redeemed invites) + invited_user_ids = InvitedUser.where(invite: invites).pluck(:user_id).compact + + invited_users = User.where(id: invited_user_ids) + + # Calculate impact of invitees + invitee_post_count = + Post + .where(user_id: invited_user_ids) + .where(created_at: date) + .where(deleted_at: nil) + .count + + invitee_topic_count = + Topic + .where(user_id: invited_user_ids) + .where(created_at: date) + .where(deleted_at: nil) + .count + + invitee_like_count = + UserAction + .where(user_id: invited_user_ids) + .where(action_type: UserAction::LIKE) + .where(created_at: date) + .count + + # Calculate average trust level of invitees + avg_trust_level = invited_users.average(:trust_level)&.to_f&.round(1) || 0 + + # Most active invitee + most_active_invitee = nil + if invited_user_ids.any? + most_active_id = + Post + .where(user_id: invited_user_ids) + .where(created_at: date) + .where(deleted_at: nil) + .group(:user_id) + .count + .max_by { |_, count| count } + &.first + + if most_active_id + most_active_user = User.find_by(id: most_active_id) + most_active_invitee = + BasicUserSerializer.new(most_active_user, root: false).as_json if most_active_user + end + end + + { + data: { + total_invites: total_invites, + redeemed_count: redeemed_count, + redemption_rate: + total_invites > 0 ? (redeemed_count.to_f / total_invites * 100).round(1) : 0, + invitee_post_count: invitee_post_count, + invitee_topic_count: invitee_topic_count, + invitee_like_count: invitee_like_count, + avg_trust_level: avg_trust_level, + most_active_invitee: most_active_invitee, + }, + identifier: "invites", + } + end + end + end +end diff --git a/app/services/discourse_rewind/action/new_user_interactions.rb b/app/services/discourse_rewind/action/new_user_interactions.rb new file mode 100644 index 0000000..cad2074 --- /dev/null +++ b/app/services/discourse_rewind/action/new_user_interactions.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Tracks how much this user interacted with new users (created this year) +# Shows veteran mentorship and community building behavior +module DiscourseRewind + module Action + class NewUserInteractions < BaseReport + def call + year_start = Date.new(date.first.year, 1, 1) + + # Find users who created accounts this year + new_user_ids = + User + .real + .where("created_at >= ? AND created_at <= ?", year_start, date.last) + .where("id != ?", user.id) + .pluck(:id) + + return if new_user_ids.empty? + + # Count likes given to new users + likes_scope = + UserAction.where( + acting_user_id: user.id, + user_id: new_user_ids, + action_type: UserAction::WAS_LIKED, + ).where(created_at: date) + likes_given = likes_scope.count + liked_user_ids = likes_scope.distinct.pluck(:user_id) + + # Count replies to new users' posts + replies_scope = + Post + .joins( + "INNER JOIN posts AS parent_posts ON posts.reply_to_post_number = parent_posts.post_number AND posts.topic_id = parent_posts.topic_id", + ) + .where(posts: { user_id: user.id, deleted_at: nil, created_at: date }) + .where("parent_posts.user_id": new_user_ids) + replies_to_new_users = replies_scope.count + replied_user_ids = replies_scope.distinct.pluck("parent_posts.user_id") + + # Count topics created by user that new users participated in + topics_with_new_users = + Topic + .joins(:posts) + .where(topics: { user_id: user.id, deleted_at: nil }) + .where(posts: { user_id: new_user_ids, deleted_at: nil }) + .where(topics: { created_at: date }) + .distinct + .count + + # Count direct messages/mentions to new users + mentions_scope = + Post + .joins( + "INNER JOIN user_actions ON user_actions.target_post_id = posts.id AND user_actions.action_type = #{UserAction::MENTION}", + ) + .where(posts: { user_id: user.id, deleted_at: nil, created_at: date }) + .where(user_actions: { user_id: new_user_ids }) + mentions_to_new_users = mentions_scope.distinct.count + mentioned_user_ids = mentions_scope.distinct.pluck("user_actions.user_id") + + # Unique new users interacted with + unique_new_users = (liked_user_ids + replied_user_ids + mentioned_user_ids).uniq.count + + total_interactions = likes_given + replies_to_new_users + mentions_to_new_users + + return if total_interactions == 0 + + { + data: { + total_interactions: total_interactions, + likes_given: likes_given, + replies_to_new_users: replies_to_new_users, + mentions_to_new_users: mentions_to_new_users, + topics_with_new_users: topics_with_new_users, + unique_new_users: unique_new_users, + new_users_count: new_user_ids.count, + }, + identifier: "new-user-interactions", + } + end + end + end +end diff --git a/app/services/discourse_rewind/action/time_of_day_activity.rb b/app/services/discourse_rewind/action/time_of_day_activity.rb new file mode 100644 index 0000000..08810e7 --- /dev/null +++ b/app/services/discourse_rewind/action/time_of_day_activity.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# Time of day activity analysis +# Shows when a user is most active (considering their timezone) +# Determines if they are a night owl or early bird +module DiscourseRewind + module Action + class TimeOfDayActivity < BaseReport + EARLY_BIRD_THRESHOLD = 6..9 + NIGHT_OWL_THRESHOLD_PM = 22..23 + NIGHT_OWL_THRESHOLD_AM = 0..2 + + def call + # Get activity by hour of day (in user's timezone) + activity_by_hour = get_activity_by_hour + + return if activity_by_hour.empty? + + total_activities = activity_by_hour.values.sum + most_active_hour = activity_by_hour.max_by { |_, count| count }&.first + personality = determine_personality(activity_by_hour, total_activities) + + { + data: { + activity_by_hour: activity_by_hour, + most_active_hour: most_active_hour, + personality: personality, + total_activities: total_activities, + }, + identifier: "time-of-day-activity", + } + end + + private + + def get_activity_by_hour + # Get user timezone offset + user_timezone = user.user_option&.timezone || "UTC" + quoted_timezone = ActiveRecord::Base.connection.quote(user_timezone) + hour_extract_sql = + Arel.sql( + "EXTRACT(HOUR FROM created_at AT TIME ZONE 'UTC' AT TIME ZONE #{quoted_timezone})::integer", + ) + + # Initialize hash with all hours + activity = (0..23).to_h { |hour| [hour, 0] } + + # Posts created + post_hours = + Post + .where(user_id: user.id) + .where(created_at: date) + .where(deleted_at: nil) + .pluck(hour_extract_sql) + .tally + + # User visits (page views) + visit_hours = + UserHistory + .where(acting_user_id: user.id) + .where(created_at: date) + .where(action: UserHistory.actions[:page_view]) + .pluck(hour_extract_sql) + .tally + + # Chat messages if chat is enabled + if SiteSetting.chat_enabled + chat_hours = + Chat::Message + .where(user_id: user.id) + .where(created_at: date) + .where(deleted_at: nil) + .pluck(hour_extract_sql) + .tally + + chat_hours.each { |hour, count| activity[hour] += count } + end + + post_hours.each { |hour, count| activity[hour] += count } + visit_hours.each { |hour, count| activity[hour] += count } + + activity + end + + def determine_personality(activity_by_hour, total_activities) + return nil if total_activities == 0 + + early_bird_activity = EARLY_BIRD_THRESHOLD.sum { |hour| activity_by_hour[hour] || 0 } + night_owl_activity = + NIGHT_OWL_THRESHOLD_PM.sum { |hour| activity_by_hour[hour] || 0 } + + NIGHT_OWL_THRESHOLD_AM.sum { |hour| activity_by_hour[hour] || 0 } + + early_bird_percentage = (early_bird_activity.to_f / total_activities * 100).round(1) + night_owl_percentage = (night_owl_activity.to_f / total_activities * 100).round(1) + + if early_bird_percentage > 20 + { type: "early_bird", percentage: early_bird_percentage } + elsif night_owl_percentage > 20 + { type: "night_owl", percentage: night_owl_percentage } + else + { type: "balanced", percentage: 0 } + end + end + end + end +end diff --git a/app/services/discourse_rewind/fetch_reports.rb b/app/services/discourse_rewind/fetch_reports.rb index 361711f..a685231 100644 --- a/app/services/discourse_rewind/fetch_reports.rb +++ b/app/services/discourse_rewind/fetch_reports.rb @@ -31,6 +31,13 @@ module DiscourseRewind Action::BestTopics, Action::BestPosts, Action::ActivityCalendar, + Action::TimeOfDayActivity, + Action::NewUserInteractions, + Action::ChatUsage, + Action::AiUsage, + Action::FavoriteGifs, + Action::Assignments, + Action::Invites, ] model :year diff --git a/spec/services/fetch_reports_spec.rb b/spec/services/fetch_reports_spec.rb index 017b117..10f4ef1 100644 --- a/spec/services/fetch_reports_spec.rb +++ b/spec/services/fetch_reports_spec.rb @@ -35,10 +35,10 @@ RSpec.describe(DiscourseRewind::FetchReports) do before { freeze_time DateTime.parse("2021-12-22") } it "returns the cached reports" do - expect(result.reports.length).to eq(9) + expect(result.reports.length).to eq(16) allow(DiscourseRewind::Action::TopWords).to receive(:call) - expect(result.reports.length).to eq(9) + expect(result.reports.length).to eq(16) expect(DiscourseRewind::Action::TopWords).to_not have_received(:call) end end @@ -51,7 +51,7 @@ RSpec.describe(DiscourseRewind::FetchReports) do it "returns the reports" do allow(DiscourseRewind::Action::TopWords).to receive(:call) - expect(result.reports.length).to eq(9) + expect(result.reports.length).to eq(16) expect(DiscourseRewind::Action::TopWords).to have_received(:call) end end