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 @@ +