diff --git a/Gemfile b/Gemfile index f388f6308..8b66d94c9 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,17 @@ gem "image_processing" # Visit and event tracking gem "ahoy_matey" +# To give group_by_day and similar methods to ActiveRecord relations +gem "groupdate" + +# Charts and graphs +gem "chartkick" + +# Geocoding for charts and other features +gem "geocoder" + +# MaxMind GeoIP2 for AhoyMatey +gem 'maxmind-geoip2', '~> 1.5', '>= 1.5.1' # Stylesheet inlining for email gem "premailer-rails" # applies any style tag classes to html elements for better email client compatibility @@ -53,15 +64,12 @@ gem "positioning", "~> 0.4.7" gem "action_policy", "~> 0.7.6" +gem "active_storage_validations" + group :development do gem "rubocop-rails-omakase", require: false end -group :test do - gem "active_storage_validations", "~> 3.0" - gem "launchy" -end - group :development, :test do gem "better_errors" # gem "binding_of_caller" # Temporarily commented - doesn't support Ruby 4.0.1 @@ -75,6 +83,7 @@ group :development, :test do gem "dotenv-rails" gem "faker" gem "factory_bot_rails" + gem "launchy" gem "listen" gem "pry-coolline" gem "pry-rails" diff --git a/Gemfile.lock b/Gemfile.lock index a06528f4f..a7306d0a3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,6 +151,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chartkick (5.2.1) childprocess (5.1.0) logger (~> 1.5) climate_control (0.2.0) @@ -182,6 +183,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.6.2) docile (1.4.1) + domain_name (0.6.20240107) dotenv (3.2.0) dotenv-rails (3.2.0) dotenv (= 3.2.0) @@ -214,9 +216,25 @@ GEM ffi (1.17.3-x86_64-darwin) ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-musl) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) + rake + geocoder (1.8.6) + base64 (>= 0.1.0) + csv (>= 3.0.0) globalid (1.3.0) activesupport (>= 6.1) + groupdate (6.7.0) + activesupport (>= 7.1) htmlentities (4.4.2) + http (5.3.1) + addressable (~> 2.8) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.5.0) + http-cookie (1.1.0) + domain_name (~> 0.5) + http-form_data (2.3.0) httparty (0.24.2) csv mini_mime (>= 1.0.0) @@ -257,6 +275,9 @@ GEM logger rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + llhttp-ffi (0.5.1) + ffi-compiler (~> 1.0) + rake (~> 13.0) logger (1.7.0) loofah (2.25.0) crass (~> 1.0.2) @@ -269,6 +290,11 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.3) + maxmind-db (1.4.0) + maxmind-geoip2 (1.5.1) + connection_pool (>= 2.2, < 4.0) + http (>= 4.3, < 6.0) + maxmind-db (~> 1.4) method_source (1.1.0) mime-types (3.7.0) logger @@ -551,7 +577,7 @@ PLATFORMS DEPENDENCIES action_policy (~> 0.7.6) - active_storage_validations (~> 3.0) + active_storage_validations ahoy_matey apipie-rails (~> 1.5.0) aws-sdk-s3 @@ -563,6 +589,7 @@ DEPENDENCIES bullet bundler-audit capybara (~> 3.36) + chartkick cocoon (~> 1.2.6) country_select debug (~> 1.11) @@ -572,6 +599,8 @@ DEPENDENCIES factory_bot_rails faker feature_flipper + geocoder + groupdate httparty image_processing jbuilder (~> 2.0) @@ -581,6 +610,7 @@ DEPENDENCIES kt-paperclip (~> 7.1.1) launchy listen + maxmind-geoip2 (~> 1.5, >= 1.5.1) ostruct positioning (~> 0.4.7) premailer-rails @@ -643,6 +673,7 @@ CHECKSUMS bullet (8.1.0) sha256=604b7e2636ec2137dcab3ba61a56248c39a0004a0c9405d58bad0686d23b98ff bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + chartkick (5.2.1) sha256=2848d7de87189f30f28d077eb0bbdebc8a1f0f6f81de1ded95008fe564369949 childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec climate_control (0.2.0) sha256=51f6a7f6a3e7b94f400592c298b32b91467400ec4580065ccc26efa522d82160 cocoon (1.2.15) sha256=d08f14e69653287d7a060ee43389b8c824e55191dffbca0c5c586f38ef491f0d @@ -662,6 +693,7 @@ CHECKSUMS devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d dotenv-rails (3.2.0) sha256=657e25554ba622ffc95d8c4f1670286510f47f2edda9f68293c3f661b303beab draper (4.0.6) sha256=e96990bffd4d98806913972f5ae2e6a7e8a5fb5433f17b80fbed7ba99d3c9236 @@ -682,8 +714,14 @@ CHECKSUMS ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5 ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 + ffi-compiler (1.3.2) sha256=a94f3d81d12caf5c5d4ecf13980a70d0aeaa72268f3b9cc13358bcc6509184a0 + geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + groupdate (6.7.0) sha256=beaa8d5bf3856814681914a1d4a20e77436a2214b85d0017dc2ea5c355fb6777 htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 + http (5.3.1) sha256=c50802d8e9be3926cb84ac3b36d1a31fbbac383bc4cbecdce9053cb604231d7d + http-cookie (1.1.0) sha256=38a5e60d1527eebc396831b8c4b9455440509881219273a6c99943d29eadbb19 + http-form_data (2.3.0) sha256=cc4eeb1361d9876821e31d7b1cf0b68f1cf874b201d27903480479d86448a5f3 httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb @@ -699,11 +737,14 @@ CHECKSUMS launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 + llhttp-ffi (0.5.1) sha256=9a25a7fc19311f691a78c9c0ac0fbf4675adbd0cca74310228fdf841018fa7bc logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + maxmind-db (1.4.0) sha256=0765c79329847fe92687a7f215a6bae843121a09ac7c8d8d9930856bcb849052 + maxmind-geoip2 (1.5.1) sha256=d1397e379367921409bf1618a4d07b667492ea0e86fcee5e062dedfbff1fe7c7 method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 mime-types-data (3.2026.0113) sha256=8c88fa7b1af91c87098f666b7ffbd4794799a71c05765be2c1f6df337d41b04c diff --git a/app/controllers/admin/ahoy_activities_controller.rb b/app/controllers/admin/ahoy_activities_controller.rb new file mode 100644 index 000000000..3043211dc --- /dev/null +++ b/app/controllers/admin/ahoy_activities_controller.rb @@ -0,0 +1,57 @@ +module Admin + class AhoyActivitiesController < Admin::BaseController + def index + end + + def visits + end + + def charts + end + + def recent + @user = (current_user.super_user? && params[:user_id].present?) ? User.find(params[:user_id]) : current_user + if params[:user_id] && params[:user_id].empty? + recent = [] + recent.concat(User.order(updated_at: :desc).limit(10)) + recent.concat(Facilitator.order(updated_at: :desc).limit(10)) + recent.concat(Banner.order(updated_at: :desc).limit(10)) + recent.concat(Faq.order(updated_at: :desc).limit(10)) + recent.concat(Event.order(updated_at: :desc).limit(10)) + recent.concat(EventRegistration.order(updated_at: :desc).limit(10)) + recent.concat(Workshop.order(updated_at: :desc).limit(10)) + recent.concat(WorkshopIdea.order(updated_at: :desc).limit(10)) + recent.concat(WorkshopLog.order(updated_at: :desc).limit(10)) + recent.concat(WorkshopVariation.order(updated_at: :desc).limit(10)) + recent.concat(Story.order(updated_at: :desc).limit(10)) + recent.concat(StoryIdea.order(updated_at: :desc).limit(10)) + recent.concat(Quote.order(updated_at: :desc).limit(10)) + recent.concat(Resource.order(updated_at: :desc).limit(10)) + recent.concat(Report.where(owner_type: "MonthlyReport").order(updated_at: :desc).limit(10)) + # recent.concat(Report.where(owner_id: 7).order(updated_at: :desc).limit(10)) # TODO: remove hard-coded + recent.concat(Address.order(updated_at: :desc).limit(10)) + recent.concat(Bookmark.order(updated_at: :desc).limit(10)) + recent.concat(Category.order(updated_at: :desc).limit(10)) + recent.concat(CommunityNews.order(updated_at: :desc).limit(10)) + recent.concat(Notification.order(updated_at: :desc).limit(10)) + recent.concat(Project.order(updated_at: :desc).limit(10)) + recent.concat(ProjectStatus.order(updated_at: :desc).limit(10)) + recent.concat(ProjectObligation.order(updated_at: :desc).limit(10)) + recent.concat(ProjectUser.order(updated_at: :desc).limit(10)) + recent.concat(Sector.order(updated_at: :desc).limit(10)) + recent.concat(WindowsType.order(updated_at: :desc).limit(10)) + + # Sort by the most recent timestamp (updated_at preferred, fallback to created_at) + recent_activities = recent.sort_by { |item| + item.try(:updated_at) || item.created_at } + .reverse.first(10 * 8) + else + recent_activities = @user.recent_activity(params[:limit] || 20) + end + @recent_activities = recent_activities + .paginate(page: params[:page], + per_page: params[:per_page] || 20) + end + + end +end diff --git a/app/controllers/admin/analytics_controller.rb b/app/controllers/admin/analytics_controller.rb index b76a26be1..34719ebc2 100644 --- a/app/controllers/admin/analytics_controller.rb +++ b/app/controllers/admin/analytics_controller.rb @@ -1,6 +1,6 @@ module Admin class AnalyticsController < Admin::BaseController - include AhoyViewTracking + include AhoyTracking protect_from_forgery with: :null_session def index diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 620d2a4b6..8be90b834 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -1,11 +1,11 @@ module Admin class BaseController < ApplicationController - before_action :require_super_user + before_action :require_admin private - def require_super_user - redirect_to root_path, alert: "Not authorized" unless current_user&.super_user? + def require_admin + redirect_to root_path, alert: "Not authorized" unless current_user&.admin? end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f823ea044..f4c0a81f5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,7 +9,7 @@ class ApplicationController < ActionController::Base # # verify_authorized # - + # rescue_from ActionPolicy::Unauthorized do |exception| flash[:alert] = exception.message.presence || "You are not authorized to perform this action." redirect_back_or_to root_path diff --git a/app/controllers/community_news_controller.rb b/app/controllers/community_news_controller.rb index 0926c567f..fe65196dc 100644 --- a/app/controllers/community_news_controller.rb +++ b/app/controllers/community_news_controller.rb @@ -1,5 +1,5 @@ class CommunityNewsController < ApplicationController - include ExternallyRedirectable, AssetUpdatable, AhoyViewTracking + include ExternallyRedirectable, AssetUpdatable, AhoyTracking before_action :set_community_news, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/concerns/ahoy_tracking.rb b/app/controllers/concerns/ahoy_tracking.rb new file mode 100644 index 000000000..c3c6a48f5 --- /dev/null +++ b/app/controllers/concerns/ahoy_tracking.rb @@ -0,0 +1,16 @@ +module AhoyTracking + extend ActiveSupport::Concern + + def track(action, resource) + Analytics::AhoyTracker.track(self, action, resource) + end + + # Sugar for controllers (readability) + def track_view(resource) = track(:view, resource) + def track_print(resource) = track(:print, resource) + def track_download(resource)= track(:download, resource) + + def track_create(resource) = track(:create, resource) + def track_update(resource) = track(:update, resource) + def track_destroy(resource) = track(:destroy, resource) +end diff --git a/app/controllers/concerns/ahoy_view_tracking.rb b/app/controllers/concerns/ahoy_view_tracking.rb deleted file mode 100644 index 5f508067b..000000000 --- a/app/controllers/concerns/ahoy_view_tracking.rb +++ /dev/null @@ -1,57 +0,0 @@ -module AhoyViewTracking - extend ActiveSupport::Concern - - def track_view(resource) - return if already_tracked?(:view, resource) - ahoy.track "view.#{resource.class.table_name.singularize}", { - resource_type: resource.class.name, - resource_id: resource.id, - resource_title: resource.decorate.title - } - end - - def track_print(resource) - return if already_tracked?(:print, resource) - ahoy.track "print.#{resource.class.table_name.singularize}", { - resource_type: resource.class.name, - resource_id: resource.id, - resource_title: resource.decorate.title - } - end - - def track_download(resource) - return if already_tracked?(:download, resource) - ahoy.track "download.#{resource.class.table_name.singularize}", { - resource_type: resource.class.name, - resource_id: resource.id, - resource_title: resource.decorate.title - } - end - - private - - def already_tracked?(action, resource) - # ---- TEST ENVIRONMENT (safe, no session, no ahoy) ---- - if Rails.env.test? && defined?(RSpec) - request_store[action] ||= Set.new - return true if request_store[action].include?(resource.id) - - request_store[action] << resource.id - return false - end - - # ---- REAL ENVIRONMENT (per visit) ---- - return false unless ahoy&.visit_token - - Ahoy::Event.joins(:visit).where( - name: "#{action}.#{resource.class.table_name.singularize}", - ahoy_visits: { visit_token: ahoy.visit_token } - ).where( - "ahoy_events.properties ->> '$.resource_id' = ?", resource.id.to_s - ).exists? - end - - def request_store - @ahoy_request_store ||= {} - end -end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d4c1c9d68..cece5eecd 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -51,47 +51,4 @@ def admin @reference_cards = reference_cards end - def recent_activities - @user = (current_user.super_user? && params[:user_id].present?) ? User.find(params[:user_id]) : current_user - if params[:user_id] && params[:user_id].empty? - recent = [] - recent.concat(User.order(updated_at: :desc).limit(10)) - recent.concat(Facilitator.order(updated_at: :desc).limit(10)) - recent.concat(Banner.order(updated_at: :desc).limit(10)) - recent.concat(Faq.order(updated_at: :desc).limit(10)) - recent.concat(Event.order(updated_at: :desc).limit(10)) - recent.concat(EventRegistration.order(updated_at: :desc).limit(10)) - recent.concat(Workshop.order(updated_at: :desc).limit(10)) - recent.concat(WorkshopIdea.order(updated_at: :desc).limit(10)) - recent.concat(WorkshopLog.order(updated_at: :desc).limit(10)) - recent.concat(WorkshopVariation.order(updated_at: :desc).limit(10)) - recent.concat(Story.order(updated_at: :desc).limit(10)) - recent.concat(StoryIdea.order(updated_at: :desc).limit(10)) - recent.concat(Quote.order(updated_at: :desc).limit(10)) - recent.concat(Resource.order(updated_at: :desc).limit(10)) - recent.concat(Report.where(owner_type: "MonthlyReport").order(updated_at: :desc).limit(10)) - # recent.concat(Report.where(owner_id: 7).order(updated_at: :desc).limit(10)) # TODO: remove hard-coded - recent.concat(Address.order(updated_at: :desc).limit(10)) - recent.concat(Bookmark.order(updated_at: :desc).limit(10)) - recent.concat(Category.order(updated_at: :desc).limit(10)) - recent.concat(CommunityNews.order(updated_at: :desc).limit(10)) - recent.concat(Notification.order(updated_at: :desc).limit(10)) - recent.concat(Project.order(updated_at: :desc).limit(10)) - recent.concat(ProjectStatus.order(updated_at: :desc).limit(10)) - recent.concat(ProjectObligation.order(updated_at: :desc).limit(10)) - recent.concat(ProjectUser.order(updated_at: :desc).limit(10)) - recent.concat(Sector.order(updated_at: :desc).limit(10)) - recent.concat(WindowsType.order(updated_at: :desc).limit(10)) - - # Sort by the most recent timestamp (updated_at preferred, fallback to created_at) - recent_activities = recent.sort_by { |item| - item.try(:updated_at) || item.created_at } - .reverse.first(10 * 8) - else - recent_activities = @user.recent_activity(params[:limit] || 20) - end - @recent_activities = recent_activities - .paginate(page: params[:page], - per_page: params[:per_page] || 20) - end end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index fd4a35933..1fbda721b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,5 +1,5 @@ class EventsController < ApplicationController - include AhoyViewTracking, AssetUpdatable + include AhoyTracking, AssetUpdatable before_action :set_event, only: %i[ show edit update destroy ] before_action :authorize_admin!, only: %i[ edit update destroy ] diff --git a/app/controllers/facilitators_controller.rb b/app/controllers/facilitators_controller.rb index 1a1638d10..55024e94f 100644 --- a/app/controllers/facilitators_controller.rb +++ b/app/controllers/facilitators_controller.rb @@ -1,5 +1,5 @@ class FacilitatorsController < ApplicationController - include AhoyViewTracking + include AhoyTracking before_action :set_facilitator, only: %i[ show edit update destroy ] def index diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a27f350d3..2ccfa87f3 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -1,5 +1,5 @@ class ProjectsController < ApplicationController - include AhoyViewTracking + include AhoyTracking before_action :set_project, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/quotes_controller.rb b/app/controllers/quotes_controller.rb index 262a8aeb3..ee871fa0b 100644 --- a/app/controllers/quotes_controller.rb +++ b/app/controllers/quotes_controller.rb @@ -1,5 +1,5 @@ class QuotesController < ApplicationController - include AhoyViewTracking + include AhoyTracking before_action :set_quote, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index 071cbb1d6..eb4a7d63f 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -1,5 +1,5 @@ class ResourcesController < ApplicationController - include ExternallyRedirectable, AssetUpdatable, AhoyViewTracking + include ExternallyRedirectable, AssetUpdatable, AhoyTracking def index authorize! diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 0f37da84f..49522f872 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -1,5 +1,5 @@ class StoriesController < ApplicationController - include ExternallyRedirectable, AssetUpdatable, AhoyViewTracking + include ExternallyRedirectable, AssetUpdatable, AhoyTracking before_action :set_story, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb index a70ffeaab..2d8006c6f 100644 --- a/app/controllers/tutorials_controller.rb +++ b/app/controllers/tutorials_controller.rb @@ -1,5 +1,5 @@ class TutorialsController < ApplicationController - include AhoyViewTracking + include AhoyTracking before_action :set_tutorial, only: [ :show, :edit, :update, :destroy ] def index diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 0e2d76364..6c0258204 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -1,5 +1,5 @@ class WorkshopVariationsController < ApplicationController - include AssetUpdatable, AhoyViewTracking + include AssetUpdatable, AhoyTracking def index unless current_user.super_user? redirect_to root_path diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index 7a3476b2b..26c1def4a 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -1,5 +1,5 @@ class WorkshopsController < ApplicationController - include AssetUpdatable, AhoyViewTracking + include AssetUpdatable, AhoyTracking def index @category_types = CategoryType.published.order(:name).decorate @sectors = Sector.published diff --git a/app/frontend/javascript/application.js b/app/frontend/javascript/application.js index dfd48449c..39d8e6e31 100644 --- a/app/frontend/javascript/application.js +++ b/app/frontend/javascript/application.js @@ -3,5 +3,7 @@ import "@rails/actiontext"; import "rhino-editor"; import "rhino-editor/exports/styles/trix.css"; +import "chartkick/chart.js" + import "./controllers"; import "./rhino/extend-editor.js"; diff --git a/app/helpers/admin_dashboard_cards_helper.rb b/app/helpers/admin_dashboard_cards_helper.rb index 51999e117..42ec01777 100644 --- a/app/helpers/admin_dashboard_cards_helper.rb +++ b/app/helpers/admin_dashboard_cards_helper.rb @@ -23,8 +23,8 @@ def system_cards # ----------------------------- def user_content_cards [ - custom_card("Activity logs", dashboard_recent_activities_path, icon: "🧭"), - custom_card("Activity counts", admin_analytics_path, icon: "📊"), + custom_card("Activity logs", activities_recent_path, icon: "🧭"), + custom_card("Activity counts", activities_counts_path, icon: "📊"), custom_card("Bookmarks tally", tally_bookmarks_path, icon: "🔖"), model_card(:event_registrations, icon: "🎟️", intensity: 100), model_card(:story_ideas, icon: "✍️", intensity: 100), diff --git a/app/models/user.rb b/app/models/user.rb index 2f93ecf46..7cc6caadd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -66,6 +66,10 @@ def self.search_by_params(params) results end + def admin? + super_user + end + def has_liasion_position_for?(project_id) !project_users.where(project_id: project_id, position: 1).first.nil? end diff --git a/app/policies/ahoy_activity_policy.rb b/app/policies/ahoy_activity_policy.rb new file mode 100644 index 000000000..4218fcaf5 --- /dev/null +++ b/app/policies/ahoy_activity_policy.rb @@ -0,0 +1,27 @@ +class AhoyActivityPolicy < ApplicationPolicy + # See https://actionpolicy.evilmartians.io/#/writing_policies + + def index? + admin? + end + + def charts? + admin? + end + + def recent? + admin? + end + + def visits? + admin? + end + + # Scoping + # See https://actionpolicy.evilmartians.io/#/scoping + # + # relation_scope do |relation| + # next relation if user.admin? + # relation.where(user: user) + # end +end diff --git a/app/services/analytics/ahoy_tracker.rb b/app/services/analytics/ahoy_tracker.rb new file mode 100644 index 000000000..daabf7056 --- /dev/null +++ b/app/services/analytics/ahoy_tracker.rb @@ -0,0 +1,116 @@ +module Analytics + class AhoyTracker + class << self + + # Core API + def track(controller, action, resource) + return unless resource.present? + + case action.to_sym + when :view, :print, :download + track_interaction(controller, action, resource) + when :create, :update, :destroy + track_record_change(controller, action, resource) + else + raise ArgumentError, "Unknown tracking action: #{action}" + end + end + + private + + # ------------------------------ + # INTERACTION EVENTS + # ------------------------------ + def track_interaction(controller, action, resource) + return if already_tracked?(controller, action, resource) + + controller.ahoy.track( + "#{action}.#{resource_name(resource)}", + base_properties(resource) + ) + end + + # ------------------------------ + # CREATE / UPDATE / DESTROY + # ------------------------------ + def track_record_change(controller, action, resource) + properties = base_properties(resource) + + case action.to_sym + when :create + properties[:snapshot] = snapshot_attributes(resource) + + when :update + changes = resource.previous_changes + .except("updated_at", "created_at", "body", "content", "metadata") + .select { |attr, _| safe_for_tracking?(attr) } + properties[:changes] = format_changes(changes) if changes.present? + + when :destroy + properties[:snapshot] = snapshot_attributes(resource) + end + + controller.ahoy.track( + "#{action}.#{resource_name(resource)}", + properties + ) + end + + # ------------------------------ + # DEDUPING + # ------------------------------ + def already_tracked?(controller, action, resource) + # ---- TEST ENV (no ahoy cookies) ---- + if Rails.env.test? && defined?(RSpec) + store = controller.instance_variable_get(:@_ahoy_request_store) || {} + store[action] ||= Set.new + return true if store[action].include?(resource.id) + + store[action] << resource.id + controller.instance_variable_set(:@_ahoy_request_store, store) + return false + end + + # ---- REAL VISIT ---- + return false unless controller.ahoy&.visit_token + + Ahoy::Event.joins(:visit).where( + name: "#{action}.#{resource_name(resource)}", + ahoy_visits: { visit_token: controller.ahoy.visit_token }, + resource_id: resource.id + ).exists? + end + + # ------------------------------ + # HELPERS + # ------------------------------ + def base_properties(resource) + { + resource_type: resource.class.name, + resource_id: resource.id, + resource_title: resource.decorate.title + } + end + + def format_changes(changes) + changes.each_with_object({}) do |(attr, (before, after)), h| + h[attr] = { before: before, after: after } + end + end + + def resource_name(resource) + resource.class.table_name.singularize + end + + def snapshot_attributes(resource) + resource.attributes + .except("updated_at", "created_at") + .select { |attr, _| safe_for_tracking?(attr) } + end + + def safe_for_tracking?(attribute) + !attribute.match?(/password|token|secret|key|digest|salt|otp/i) + end + end + end +end diff --git a/app/views/dashboard/_recent_activity.html.erb b/app/views/admin/ahoy_activities/_recent_activity.html.erb similarity index 100% rename from app/views/dashboard/_recent_activity.html.erb rename to app/views/admin/ahoy_activities/_recent_activity.html.erb diff --git a/app/views/admin/ahoy_activities/charts.html.erb b/app/views/admin/ahoy_activities/charts.html.erb new file mode 100644 index 000000000..46a030cbb --- /dev/null +++ b/app/views/admin/ahoy_activities/charts.html.erb @@ -0,0 +1,83 @@ +
+
+ + <%= column_chart Ahoy::Visit.group_by_hour_of_day(:started_at, format: "%l %P").count, + title: "Average visits by Hour" %> + <%= column_chart Ahoy::Visit.group_by_day_of_week(:started_at, format: "%A").count, + title: "Average visits by Weekday" %> + <%= bar_chart Ahoy::Visit.where.not(city: nil) + .group(:city) + .count + .sort_by { |_city, count| -count } + .first(10) + .to_h, + title: "Top Cities" %> + <%= pie_chart Ahoy::Visit.group(:country).count, + title: "Visits by Country" %> + <%= bar_chart Ahoy::Visit.group([:referring_domain, :landing_page]) + .count.sort_by { |_k, v| -v }.first(10).to_h, + title: "Top Referrer → Landing Pages" %> + <%= bar_chart Ahoy::Visit.group(:landing_page) + .order("count_id DESC") + .limit(10) + .count(:id), + title: "Top Landing Pages", height: "350px" %> +
+ +
+
+ <%= line_chart Ahoy::Visit.group_by_day(:started_at).count, + xtitle: "Date", + ytitle: "Visits", + height: "300px", + width: "100%", + title: "Visits by date" %> +
+ +
+ <%= bar_chart Ahoy::Event.group(:visit_id).count.sort_by { |_k, v| -v }.first(10).to_h, + title: "Most Engaged Visits" %> +
+ +
+ <% single_page_visits = Ahoy::Event.group(:visit_id).having("count(*) = 1").count %> + <%= pie_chart single_page_visits, + title: "Single-Event Visits (Bounce Proxy)" %> +
+
+ <%= pie_chart ({"Logged In" => Ahoy::Visit.where.not(user_id: nil).count, + "Anonymous" => Ahoy::Visit.where(user_id: nil).count }), + title: "Login status" %> +
+ +
+ <%= pie_chart Ahoy::Visit.group(:device_type).count, + title: "Device Type" %> +
+ +
+ <%= pie_chart Ahoy::Visit.group(:utm_source).count, + title: "UTM Sources" %> +
+ +
+ <%= pie_chart Ahoy::Visit.group(:referring_domain).count, + title: "Referring Domains" %> +
+ +
+ <%= pie_chart Ahoy::Visit.group(:browser).count, + title: "Browsers" %> +
+ + <%= pie_chart Ahoy::Visit.group(:visitor_token).having("count(*) = 1").count, + title: "New Visitors" %> + + <%= pie_chart Ahoy::Visit.group(:visitor_token).having("count(*) > 1").count, + title: "Returning Visitors" %> + <%= line_chart Ahoy::Visit.group_by_day(:started_at).group(:device_type).count, + title: "Device Type Over Time" %> + <%= line_chart Ahoy::Visit.group_by_day(:started_at).group(:landing_page).count, + title: "Landing Page Trend" %> +
+
diff --git a/app/views/dashboard/recent_activities.html.erb b/app/views/admin/ahoy_activities/recent.html.erb similarity index 100% rename from app/views/dashboard/recent_activities.html.erb rename to app/views/admin/ahoy_activities/recent.html.erb diff --git a/app/views/admin/ahoy_activities/visits.html.erb b/app/views/admin/ahoy_activities/visits.html.erb new file mode 100644 index 000000000..e69de29bb diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb index 2b51a11a8..d018ddc36 100644 --- a/app/views/admin/analytics/index.html.erb +++ b/app/views/admin/analytics/index.html.erb @@ -6,7 +6,7 @@ Activity counts - <%= form_with url: admin_analytics_path, method: :get, local: true, class: "flex items-center gap-2" do |f| %> + <%= form_with url: activities_counts_path, method: :get, local: true, class: "flex items-center gap-2" do |f| %> <%= f.label :time_period, "Time period:", class: "text-sm font-medium text-gray-700" %> <%= f.select :time_period, options_for_select([ diff --git a/app/views/workshops/_index_row.html.erb b/app/views/workshops/_index_row.html.erb index ef840d84d..641e4c8ca 100644 --- a/app/views/workshops/_index_row.html.erb +++ b/app/views/workshops/_index_row.html.erb @@ -80,7 +80,14 @@ <% if workshop.age_ranges.any? %>
- Age ranges: <%= workshop.age_ranges.pluck(:name).to_sentence %> + Age ranges: <%= Category + .joins(:categorizable_items, :category_type) + .where( + categorizable_items: { categorizable: workshop.object }, + category_types: { name: "AgeRange" } + ) + .pluck(:name) + .to_sentence %>
<% end %> diff --git a/config/initializers/ahoy.rb b/config/initializers/ahoy.rb index bbd82749c..c18adcaf6 100644 --- a/config/initializers/ahoy.rb +++ b/config/initializers/ahoy.rb @@ -7,4 +7,16 @@ class Ahoy::Store < Ahoy::DatabaseStore # set to true for geocoding (and add the geocoder gem to your Gemfile) # we recommend configuring local geocoding as well # see https://github.com/ankane/ahoy#geocoding -Ahoy.geocode = false +Ahoy.geocode = true + +# customize Ahoy::Event to extract resource dimensions from properties +Ahoy::Event.class_eval do + before_validation :extract_resource_dimensions + + def extract_resource_dimensions + return unless properties + + self.resource_type ||= properties["resource_type"] + self.resource_id ||= properties["resource_id"] + end +end \ No newline at end of file diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb new file mode 100644 index 000000000..50f78e153 --- /dev/null +++ b/config/initializers/geocoder.rb @@ -0,0 +1,6 @@ +Geocoder.configure( + lookup: :maxmind_local, + maxmind_local: { + file: Rails.root.join("db/geoip/GeoLite2-City.mmdb") + } +) diff --git a/config/routes.rb b/config/routes.rb index 02007d53b..4ee2c5147 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,7 +29,6 @@ get "contact_us", to: "contact_us#index" post "contact_us", to: "contact_us#create" get "dashboard/admin", to: "dashboard#admin" - get "dashboard/recent_activities", to: "dashboard#recent_activities" get "image_migration_audit", to: "image_migration_audit#index" get "taggings", to: "taggings#index", as: "taggings" @@ -39,10 +38,16 @@ get "tags/categories", to: "tags#categories", as: "tags_categories" namespace :admin do - get "analytics", to: "analytics#index" + post "analytics/print", to: "analytics#print" end + scope :activities, as: :activities do + get "/", to: "admin/ahoy_activities#index", as: "" + get :counts, to: "admin/analytics#index", as: :counts + get :charts, to: "admin/ahoy_activities#charts", as: :charts + get :recent, to: "admin/ahoy_activities#recent", as: :recent + end resources :banners resources :bookmarks do post :search diff --git a/db/geoip/GeoLite2-City.mmdb b/db/geoip/GeoLite2-City.mmdb new file mode 100644 index 000000000..b7505272e Binary files /dev/null and b/db/geoip/GeoLite2-City.mmdb differ diff --git a/db/migrate/20260131193429_add_resource_dimensions_to_ahoy_events.rb b/db/migrate/20260131193429_add_resource_dimensions_to_ahoy_events.rb new file mode 100644 index 000000000..31696f49d --- /dev/null +++ b/db/migrate/20260131193429_add_resource_dimensions_to_ahoy_events.rb @@ -0,0 +1,9 @@ +class AddResourceDimensionsToAhoyEvents < ActiveRecord::Migration[8.1] + def change + add_column :ahoy_events, :resource_type, :string + add_column :ahoy_events, :resource_id, :bigint + + add_index :ahoy_events, [:resource_type, :resource_id, :time] + add_index :ahoy_events, :resource_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 9153e0909..c4484acbb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -129,17 +129,17 @@ t.string "country" t.string "device_type" t.string "ip" - t.text "landing_page", size: :medium + t.text "landing_page" t.float "latitude" t.float "longitude" t.string "os" t.string "os_version" t.string "platform" - t.text "referrer", size: :medium + t.text "referrer" t.string "referring_domain" t.string "region" t.datetime "started_at" - t.text "user_agent", size: :medium + t.text "user_agent" t.bigint "user_id" t.string "utm_campaign" t.string "utm_content" @@ -342,7 +342,7 @@ t.string "last_name", null: false t.string "linked_in_url" t.date "member_since" - t.text "notes", size: :medium + t.text "notes" t.boolean "profile_is_searchable", default: true, null: false t.boolean "profile_show_affiliations", default: true, null: false t.boolean "profile_show_bio", default: true, null: false @@ -487,9 +487,9 @@ create_table "notifications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "delivered_at" - t.text "email_body_html", size: :medium - t.text "email_body_text", size: :medium - t.text "email_subject", size: :medium + t.text "email_body_html" + t.text "email_body_text" + t.text "email_subject" t.string "kind", null: false t.integer "noticeable_id" t.string "noticeable_type" @@ -747,7 +747,7 @@ end create_table "tutorials", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.text "body", size: :medium + t.text "body" t.datetime "created_at", null: false t.boolean "featured", default: false, null: false t.integer "position", default: 10, null: false @@ -1078,7 +1078,6 @@ end add_foreign_key "action_text_mentions", "action_text_rich_texts" - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "age_ranges", "windows_types" add_foreign_key "banners", "users", column: "created_by_id" diff --git a/package-lock.json b/package-lock.json index fe8b52101..bddce8319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@tiptap/extension-text-align": "^3.13.0", "@tiptap/extension-youtube": "^3.13.0", "@tiptap/suggestion": "^3.15.3", + "chart.js": "^4.5.1", + "chartkick": "^5.0.1", "from": "^0.1.7", "rhino-editor": "^0.18.0", "sortablejs": "^1.15.6", @@ -496,6 +498,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", @@ -1695,6 +1703,40 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "license": "MIT", + "optional": true, + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, + "node_modules/chartkick": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chartkick/-/chartkick-5.0.1.tgz", + "integrity": "sha512-4F3tWI3eBQgnjCYZIZ+fHOaJuNyxeyhDE2Tm+voOWB19hDjSJceys/spzN52DOn8bWepNESGXvPVTGU1jeFsbA==", + "license": "MIT", + "optionalDependencies": { + "chart.js": "4", + "chartjs-adapter-date-fns": ">=3", + "date-fns": ">=2" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1723,6 +1765,17 @@ "node": ">=4" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 3e17a5888..69441e1c8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@tiptap/extension-text-align": "^3.13.0", "@tiptap/extension-youtube": "^3.13.0", "@tiptap/suggestion": "^3.15.3", + "chart.js": "^4.5.1", + "chartkick": "^5.0.1", "from": "^0.1.7", "rhino-editor": "^0.18.0", "sortablejs": "^1.15.6", diff --git a/spec/controllers/concerns/ahoy_view_tracking_spec.rb b/spec/controllers/concerns/ahoy_view_tracking_spec.rb index 7fd9f83de..f1779a811 100644 --- a/spec/controllers/concerns/ahoy_view_tracking_spec.rb +++ b/spec/controllers/concerns/ahoy_view_tracking_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" -RSpec.describe AhoyViewTracking, type: :controller do +RSpec.describe AhoyTracking, type: :controller do controller(ApplicationController) do - include AhoyViewTracking + include AhoyTracking def index workshop = Workshop.find(params[:id])