๐ค Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
| @@ -19,6 +19,10 @@ gem "jbuilder" | ||
| 19 | 19 | |
| 20 | 20 | gem "jwt" # for google signin |
| 21 | 21 | |
| 22 | +# Analytics and dashboard | |
| 23 | +gem "ahoy_matey" | |
| 24 | +gem "blazer" | |
| 25 | + | |
| 22 | 26 | # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] |
| 23 | 27 | # gem "bcrypt", "~> 3.1.7" |
| 24 | 28 |
| @@ -74,26 +74,39 @@ GEM | ||
| 74 | 74 | uri (>= 0.13.1) |
| 75 | 75 | addressable (2.8.7) |
| 76 | 76 | public_suffix (>= 2.0.2, < 7.0) |
| 77 | + ahoy_matey (5.4.0) | |
| 78 | + activesupport (>= 7.1) | |
| 79 | + device_detector (>= 1) | |
| 80 | + safely_block (>= 0.4) | |
| 77 | 81 | ast (2.4.3) |
| 78 | 82 | base64 (0.3.0) |
| 79 | 83 | bcrypt_pbkdf (1.1.1) |
| 80 | 84 | benchmark (0.4.1) |
| 81 | 85 | bigdecimal (3.2.3) |
| 82 | 86 | bindex (0.8.1) |
| 87 | + blazer (3.3.0) | |
| 88 | + activerecord (>= 7.1) | |
| 89 | + chartkick (>= 5) | |
| 90 | + csv | |
| 91 | + railties (>= 7.1) | |
| 92 | + safely_block (>= 0.4) | |
| 83 | 93 | bootsnap (1.18.6) |
| 84 | 94 | msgpack (~> 1.2) |
| 85 | 95 | brakeman (7.1.0) |
| 86 | 96 | racc |
| 87 | 97 | builder (3.3.0) |
| 98 | + chartkick (5.2.0) | |
| 88 | 99 | childprocess (5.1.0) |
| 89 | 100 | logger (~> 1.5) |
| 90 | 101 | concurrent-ruby (1.3.5) |
| 91 | 102 | connection_pool (2.5.4) |
| 92 | 103 | crass (1.0.6) |
| 104 | + csv (3.3.5) | |
| 93 | 105 | date (3.4.1) |
| 94 | 106 | debug (1.11.0) |
| 95 | 107 | irb (~> 1.10) |
| 96 | 108 | reline (>= 0.3.8) |
| 109 | + device_detector (1.1.3) | |
| 97 | 110 | diff-lcs (1.6.2) |
| 98 | 111 | dotenv (3.1.8) |
| 99 | 112 | drb (2.2.3) |
| @@ -297,6 +310,7 @@ GEM | ||
| 297 | 310 | rubocop-performance (>= 1.24) |
| 298 | 311 | rubocop-rails (>= 2.30) |
| 299 | 312 | ruby-progressbar (1.13.0) |
| 313 | + safely_block (0.5.0) | |
| 300 | 314 | securerandom (0.4.1) |
| 301 | 315 | solid_cable (3.0.12) |
| 302 | 316 | actioncable (>= 7.2) |
| @@ -370,6 +384,8 @@ PLATFORMS | ||
| 370 | 384 | x86_64-linux-musl |
| 371 | 385 | |
| 372 | 386 | DEPENDENCIES |
| 387 | + ahoy_matey | |
| 388 | + blazer | |
| 373 | 389 | bootsnap |
| 374 | 390 | brakeman |
| 375 | 391 | debug |
| @@ -1,5 +1,19 @@ | ||
| 1 | 1 | class ApplicationController < ActionController::Base |
| 2 | 2 | include Authentication |
| 3 | + include ApplicationHelper | |
| 4 | + | |
| 3 | 5 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. |
| 4 | 6 | allow_browser versions: :modern |
| 7 | + | |
| 8 | + private | |
| 9 | + | |
| 10 | + def track_action | |
| 11 | + ahoy.track "Action", request.path_parameters | |
| 12 | + end | |
| 13 | + | |
| 14 | + def require_admin | |
| 15 | + unless admin_user? | |
| 16 | + render plain: "Access denied - Admin privileges required", status: 403 | |
| 17 | + end | |
| 18 | + end | |
| 5 | 19 | end |
| @@ -1,2 +1,5 @@ | ||
| 1 | 1 | module ApplicationHelper |
| 2 | + def admin_user? | |
| 3 | + authenticated? && Rails.application.config.admin_user_ids.include?(Current.user.id) | |
| 4 | + end | |
| 2 | 5 | end |
| @@ -0,0 +1,10 @@ | ||
| 1 | +class Ahoy::Event < ApplicationRecord | |
| 2 | + include Ahoy::QueryMethods | |
| 3 | + | |
| 4 | + self.table_name = "ahoy_events" | |
| 5 | + | |
| 6 | + belongs_to :visit | |
| 7 | + belongs_to :user, optional: true | |
| 8 | + | |
| 9 | + serialize :properties, coder: JSON | |
| 10 | +end |
| @@ -0,0 +1,6 @@ | ||
| 1 | +class Ahoy::Visit < ApplicationRecord | |
| 2 | + self.table_name = "ahoy_visits" | |
| 3 | + | |
| 4 | + has_many :events, class_name: "Ahoy::Event" | |
| 5 | + belongs_to :user, optional: true | |
| 6 | +end |
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | <%# Includes all stylesheet files in app/assets/stylesheets %> |
| 25 | 25 | <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> |
| 26 | 26 | <%= javascript_importmap_tags %> |
| 27 | + <%= javascript_include_tag "https://unpkg.com/ahoy.js@0.4.2/dist/ahoy.js" %> | |
| 27 | 28 | </head> |
| 28 | 29 | |
| 29 | 30 | <body> |
| @@ -38,6 +39,11 @@ | ||
| 38 | 39 | <li class="nav-item"> |
| 39 | 40 | <%= link_to "Built with Rails", built_with_rails_path, class: "nav-link" %> |
| 40 | 41 | </li> |
| 42 | + <% if admin_user? %> | |
| 43 | + <li class="nav-item"> | |
| 44 | + <%= link_to "Analytics", "/blazer", class: "nav-link text-warning" %> | |
| 45 | + </li> | |
| 46 | + <% end %> | |
| 41 | 47 | <li class="nav-item"> |
| 42 | 48 | <% if authenticated? %> |
| 43 | 49 | <%= button_to "Sign Out", session_path, method: :delete, |
| @@ -0,0 +1,79 @@ | ||
| 1 | +# see https://github.com/ankane/blazer for more info | |
| 2 | + | |
| 3 | +data_sources: | |
| 4 | + main: | |
| 5 | + url: <%= Rails.env.production? ? "sqlite3:/rails/storage/production.sqlite3" : "sqlite3:storage/development.sqlite3" %> | |
| 6 | + | |
| 7 | + # statement timeout, in seconds | |
| 8 | + # none by default | |
| 9 | + # timeout: 15 | |
| 10 | + | |
| 11 | + # caching settings | |
| 12 | + # can greatly improve speed | |
| 13 | + # off by default | |
| 14 | + # cache: | |
| 15 | + # mode: slow # or all | |
| 16 | + # expires_in: 60 # min | |
| 17 | + # slow_threshold: 15 # sec, only used in slow mode | |
| 18 | + | |
| 19 | + # wrap queries in a transaction for safety | |
| 20 | + # not necessary if you use a read-only user | |
| 21 | + # true by default | |
| 22 | + # use_transaction: false | |
| 23 | + | |
| 24 | + smart_variables: | |
| 25 | + # zone_id: "SELECT id, name FROM zones ORDER BY name ASC" | |
| 26 | + # period: ["day", "week", "month"] | |
| 27 | + # status: {0: "Active", 1: "Archived"} | |
| 28 | + | |
| 29 | + linked_columns: | |
| 30 | + # user_id: "/admin/users/{value}" | |
| 31 | + | |
| 32 | + smart_columns: | |
| 33 | + # user_id: "SELECT id, name FROM users WHERE id IN {value}" | |
| 34 | + | |
| 35 | +# create audits | |
| 36 | +audit: true | |
| 37 | + | |
| 38 | +# change the time zone | |
| 39 | +# time_zone: "Pacific Time (US & Canada)" | |
| 40 | + | |
| 41 | +# class name of the user model | |
| 42 | +# user_class: User | |
| 43 | + | |
| 44 | +# method name for the current user | |
| 45 | +# user_method: current_user | |
| 46 | + | |
| 47 | +# method name for the display name | |
| 48 | +# user_name: name | |
| 49 | + | |
| 50 | +# custom before_action to use for auth | |
| 51 | +before_action_method: require_admin | |
| 52 | + | |
| 53 | +# email to send checks from | |
| 54 | +# from_email: blazer@example.org | |
| 55 | + | |
| 56 | +# webhook for Slack | |
| 57 | +# slack_webhook_url: <%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %> | |
| 58 | + | |
| 59 | +check_schedules: | |
| 60 | + - "1 day" | |
| 61 | + - "1 hour" | |
| 62 | + - "5 minutes" | |
| 63 | + | |
| 64 | +# enable anomaly detection | |
| 65 | +# note: with trend, time series are sent to https://trendapi.org | |
| 66 | +# anomaly_checks: prophet / trend / anomaly_detection | |
| 67 | + | |
| 68 | +# enable forecasting | |
| 69 | +# note: with trend, time series are sent to https://trendapi.org | |
| 70 | +# forecasting: prophet / trend | |
| 71 | + | |
| 72 | +# enable map | |
| 73 | +# mapbox_access_token: <%= ENV["MAPBOX_ACCESS_TOKEN"] %> | |
| 74 | + | |
| 75 | +# enable uploads | |
| 76 | +# uploads: | |
| 77 | +# url: <%= ENV["BLAZER_UPLOADS_URL"] %> | |
| 78 | +# schema: uploads | |
| 79 | +# data_source: main |
| @@ -0,0 +1,7 @@ | ||
| 1 | +# Admin configuration | |
| 2 | +Rails.application.configure do | |
| 3 | + # List of user IDs that should have access to Blazer analytics | |
| 4 | + config.admin_user_ids = [ | |
| 5 | + 1 # Add actual admin user IDs here | |
| 6 | + ] | |
| 7 | +end |
| @@ -0,0 +1,10 @@ | ||
| 1 | +class Ahoy::Store < Ahoy::DatabaseStore | |
| 2 | +end | |
| 3 | + | |
| 4 | +# set to true for JavaScript tracking | |
| 5 | +Ahoy.api = false | |
| 6 | + | |
| 7 | +# set to true for geocoding (and add the geocoder gem to your Gemfile) | |
| 8 | +# we recommend configuring local geocoding as well | |
| 9 | +# see https://github.com/ankane/ahoy#geocoding | |
| 10 | +Ahoy.geocode = false |
| @@ -1,3 +1,5 @@ | ||
| 1 | +require "blazer" | |
| 2 | + | |
| 1 | 3 | Rails.application.routes.draw do |
| 2 | 4 | resource :session do |
| 3 | 5 | member do |
| @@ -13,6 +15,9 @@ Rails.application.routes.draw do | ||
| 13 | 15 | post "google-signin", to: "sessions#create_for_google", as: :google_signin |
| 14 | 16 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html |
| 15 | 17 | |
| 18 | + # Analytics dashboard - protected route | |
| 19 | + mount Blazer::Engine, at: "blazer" | |
| 20 | + | |
| 16 | 21 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. |
| 17 | 22 | # Can be used by load balancers and uptime monitors to verify that the app is live. |
| 18 | 23 | get "up" => "rails/health#show", as: :rails_health_check |
| @@ -0,0 +1,61 @@ | ||
| 1 | +class CreateAhoyVisitsAndEvents < ActiveRecord::Migration[8.0] | |
| 2 | + def change | |
| 3 | + create_table :ahoy_visits do |t| | |
| 4 | + t.string :visit_token | |
| 5 | + t.string :visitor_token | |
| 6 | + | |
| 7 | + # the rest are recommended but optional | |
| 8 | + # simply remove any you don't want | |
| 9 | + | |
| 10 | + # user | |
| 11 | + t.references :user | |
| 12 | + | |
| 13 | + # standard | |
| 14 | + t.string :ip | |
| 15 | + t.text :user_agent | |
| 16 | + t.text :referrer | |
| 17 | + t.string :referring_domain | |
| 18 | + t.text :landing_page | |
| 19 | + | |
| 20 | + # technology | |
| 21 | + t.string :browser | |
| 22 | + t.string :os | |
| 23 | + t.string :device_type | |
| 24 | + | |
| 25 | + # location | |
| 26 | + t.string :country | |
| 27 | + t.string :region | |
| 28 | + t.string :city | |
| 29 | + t.float :latitude | |
| 30 | + t.float :longitude | |
| 31 | + | |
| 32 | + # utm parameters | |
| 33 | + t.string :utm_source | |
| 34 | + t.string :utm_medium | |
| 35 | + t.string :utm_term | |
| 36 | + t.string :utm_content | |
| 37 | + t.string :utm_campaign | |
| 38 | + | |
| 39 | + # native apps | |
| 40 | + t.string :app_version | |
| 41 | + t.string :os_version | |
| 42 | + t.string :platform | |
| 43 | + | |
| 44 | + t.datetime :started_at | |
| 45 | + end | |
| 46 | + | |
| 47 | + add_index :ahoy_visits, :visit_token, unique: true | |
| 48 | + add_index :ahoy_visits, [ :visitor_token, :started_at ] | |
| 49 | + | |
| 50 | + create_table :ahoy_events do |t| | |
| 51 | + t.references :visit | |
| 52 | + t.references :user | |
| 53 | + | |
| 54 | + t.string :name | |
| 55 | + t.text :properties | |
| 56 | + t.datetime :time | |
| 57 | + end | |
| 58 | + | |
| 59 | + add_index :ahoy_events, [ :name, :time ] | |
| 60 | + end | |
| 61 | +end |
| @@ -0,0 +1,47 @@ | ||
| 1 | +class InstallBlazer < ActiveRecord::Migration[8.0] | |
| 2 | + def change | |
| 3 | + create_table :blazer_queries do |t| | |
| 4 | + t.references :creator | |
| 5 | + t.string :name | |
| 6 | + t.text :description | |
| 7 | + t.text :statement | |
| 8 | + t.string :data_source | |
| 9 | + t.string :status | |
| 10 | + t.timestamps null: false | |
| 11 | + end | |
| 12 | + | |
| 13 | + create_table :blazer_audits do |t| | |
| 14 | + t.references :user | |
| 15 | + t.references :query | |
| 16 | + t.text :statement | |
| 17 | + t.string :data_source | |
| 18 | + t.datetime :created_at | |
| 19 | + end | |
| 20 | + | |
| 21 | + create_table :blazer_dashboards do |t| | |
| 22 | + t.references :creator | |
| 23 | + t.string :name | |
| 24 | + t.timestamps null: false | |
| 25 | + end | |
| 26 | + | |
| 27 | + create_table :blazer_dashboard_queries do |t| | |
| 28 | + t.references :dashboard | |
| 29 | + t.references :query | |
| 30 | + t.integer :position | |
| 31 | + t.timestamps null: false | |
| 32 | + end | |
| 33 | + | |
| 34 | + create_table :blazer_checks do |t| | |
| 35 | + t.references :creator | |
| 36 | + t.references :query | |
| 37 | + t.string :state | |
| 38 | + t.string :schedule | |
| 39 | + t.text :emails | |
| 40 | + t.text :slack_channels | |
| 41 | + t.string :check_type | |
| 42 | + t.text :message | |
| 43 | + t.datetime :last_run_at | |
| 44 | + t.timestamps null: false | |
| 45 | + end | |
| 46 | + end | |
| 47 | +end |
| @@ -10,7 +10,105 @@ | ||
| 10 | 10 | # |
| 11 | 11 | # It's strongly recommended that you check this file into your version control system. |
| 12 | 12 | |
| 13 | -ActiveRecord::Schema[8.0].define(version: 2025_09_29_043752) do | |
| 13 | +ActiveRecord::Schema[8.0].define(version: 2025_09_30_004923) do | |
| 14 | + create_table "ahoy_events", force: :cascade do |t| | |
| 15 | + t.integer "visit_id" | |
| 16 | + t.integer "user_id" | |
| 17 | + t.string "name" | |
| 18 | + t.text "properties" | |
| 19 | + t.datetime "time" | |
| 20 | + t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time" | |
| 21 | + t.index ["user_id"], name: "index_ahoy_events_on_user_id" | |
| 22 | + t.index ["visit_id"], name: "index_ahoy_events_on_visit_id" | |
| 23 | + end | |
| 24 | + | |
| 25 | + create_table "ahoy_visits", force: :cascade do |t| | |
| 26 | + t.string "visit_token" | |
| 27 | + t.string "visitor_token" | |
| 28 | + t.integer "user_id" | |
| 29 | + t.string "ip" | |
| 30 | + t.text "user_agent" | |
| 31 | + t.text "referrer" | |
| 32 | + t.string "referring_domain" | |
| 33 | + t.text "landing_page" | |
| 34 | + t.string "browser" | |
| 35 | + t.string "os" | |
| 36 | + t.string "device_type" | |
| 37 | + t.string "country" | |
| 38 | + t.string "region" | |
| 39 | + t.string "city" | |
| 40 | + t.float "latitude" | |
| 41 | + t.float "longitude" | |
| 42 | + t.string "utm_source" | |
| 43 | + t.string "utm_medium" | |
| 44 | + t.string "utm_term" | |
| 45 | + t.string "utm_content" | |
| 46 | + t.string "utm_campaign" | |
| 47 | + t.string "app_version" | |
| 48 | + t.string "os_version" | |
| 49 | + t.string "platform" | |
| 50 | + t.datetime "started_at" | |
| 51 | + t.index ["user_id"], name: "index_ahoy_visits_on_user_id" | |
| 52 | + t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true | |
| 53 | + t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at" | |
| 54 | + end | |
| 55 | + | |
| 56 | + create_table "blazer_audits", force: :cascade do |t| | |
| 57 | + t.integer "user_id" | |
| 58 | + t.integer "query_id" | |
| 59 | + t.text "statement" | |
| 60 | + t.string "data_source" | |
| 61 | + t.datetime "created_at" | |
| 62 | + t.index ["query_id"], name: "index_blazer_audits_on_query_id" | |
| 63 | + t.index ["user_id"], name: "index_blazer_audits_on_user_id" | |
| 64 | + end | |
| 65 | + | |
| 66 | + create_table "blazer_checks", force: :cascade do |t| | |
| 67 | + t.integer "creator_id" | |
| 68 | + t.integer "query_id" | |
| 69 | + t.string "state" | |
| 70 | + t.string "schedule" | |
| 71 | + t.text "emails" | |
| 72 | + t.text "slack_channels" | |
| 73 | + t.string "check_type" | |
| 74 | + t.text "message" | |
| 75 | + t.datetime "last_run_at" | |
| 76 | + t.datetime "created_at", null: false | |
| 77 | + t.datetime "updated_at", null: false | |
| 78 | + t.index ["creator_id"], name: "index_blazer_checks_on_creator_id" | |
| 79 | + t.index ["query_id"], name: "index_blazer_checks_on_query_id" | |
| 80 | + end | |
| 81 | + | |
| 82 | + create_table "blazer_dashboard_queries", force: :cascade do |t| | |
| 83 | + t.integer "dashboard_id" | |
| 84 | + t.integer "query_id" | |
| 85 | + t.integer "position" | |
| 86 | + t.datetime "created_at", null: false | |
| 87 | + t.datetime "updated_at", null: false | |
| 88 | + t.index ["dashboard_id"], name: "index_blazer_dashboard_queries_on_dashboard_id" | |
| 89 | + t.index ["query_id"], name: "index_blazer_dashboard_queries_on_query_id" | |
| 90 | + end | |
| 91 | + | |
| 92 | + create_table "blazer_dashboards", force: :cascade do |t| | |
| 93 | + t.integer "creator_id" | |
| 94 | + t.string "name" | |
| 95 | + t.datetime "created_at", null: false | |
| 96 | + t.datetime "updated_at", null: false | |
| 97 | + t.index ["creator_id"], name: "index_blazer_dashboards_on_creator_id" | |
| 98 | + end | |
| 99 | + | |
| 100 | + create_table "blazer_queries", force: :cascade do |t| | |
| 101 | + t.integer "creator_id" | |
| 102 | + t.string "name" | |
| 103 | + t.text "description" | |
| 104 | + t.text "statement" | |
| 105 | + t.string "data_source" | |
| 106 | + t.string "status" | |
| 107 | + t.datetime "created_at", null: false | |
| 108 | + t.datetime "updated_at", null: false | |
| 109 | + t.index ["creator_id"], name: "index_blazer_queries_on_creator_id" | |
| 110 | + end | |
| 111 | + | |
| 14 | 112 | create_table "posts", force: :cascade do |t| |
| 15 | 113 | t.string "title", null: false |
| 16 | 114 | t.string "slug", null: false |
| @@ -42,12 +42,12 @@ posts_data = [ | ||
| 42 | 42 | }, |
| 43 | 43 | { |
| 44 | 44 | title: "Ahoy & Blazer analytics integration", |
| 45 | - slug: "ahoy-blazer-analytics", | |
| 46 | - summary: "Adding user analytics and business intelligence to Rails apps with Ahoy and Blazer", | |
| 47 | - read_time: "6 min read", | |
| 48 | - youtube_slug: nil, | |
| 49 | - published_date: Date.new(2025, 12, 1), | |
| 50 | - status: "draft" | |
| 45 | + slug: "ahoy-blazer-analytics-claude-voice-cli", | |
| 46 | + summary: "Adding user analytics and business intelligence to Rails apps with Ahoy and Blazer - implemented with Claude Code voice control", | |
| 47 | + read_time: "8 min read", | |
| 48 | + youtube_slug: "VT0iyi8y8Q4", | |
| 49 | + published_date: Date.new(2025, 9, 30), | |
| 50 | + status: "published" | |
| 51 | 51 | } |
| 52 | 52 | ] |
| 53 | 53 | |
| @@ -64,4 +64,69 @@ posts_data.each do |post_attrs| | ||
| 64 | 64 | end |
| 65 | 65 | end |
| 66 | 66 | |
| 67 | +# Blazer queries for analytics | |
| 68 | +puts "Creating Blazer queries..." | |
| 69 | + | |
| 70 | +blazer_queries = [ | |
| 71 | + { | |
| 72 | + name: "Referrals by Domain", | |
| 73 | + description: "Track website visits grouped by referring domain", | |
| 74 | + statement: <<~SQL | |
| 75 | + SELECT#{' '} | |
| 76 | + referring_domain, | |
| 77 | + COUNT(*) as visits, | |
| 78 | + COUNT(DISTINCT visitor_token) as unique_visitors, | |
| 79 | + DATE(started_at) as visit_date | |
| 80 | + FROM ahoy_visits#{' '} | |
| 81 | + WHERE referring_domain IS NOT NULL#{' '} | |
| 82 | + AND started_at >= date('now', '-30 days') | |
| 83 | + GROUP BY referring_domain, DATE(started_at) | |
| 84 | + ORDER BY visits DESC, visit_date DESC | |
| 85 | + LIMIT 50 | |
| 86 | + SQL | |
| 87 | + }, | |
| 88 | + { | |
| 89 | + name: "Visits by Landing Page", | |
| 90 | + description: "Track website visits grouped by landing page URL", | |
| 91 | + statement: <<~SQL | |
| 92 | + SELECT#{' '} | |
| 93 | + landing_page, | |
| 94 | + COUNT(*) as visits, | |
| 95 | + COUNT(DISTINCT visitor_token) as unique_visitors, | |
| 96 | + DATE(started_at) as visit_date | |
| 97 | + FROM ahoy_visits#{' '} | |
| 98 | + WHERE started_at >= date('now', '-30 days') | |
| 99 | + GROUP BY landing_page, DATE(started_at) | |
| 100 | + ORDER BY visits DESC, visit_date DESC | |
| 101 | + LIMIT 50 | |
| 102 | + SQL | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + name: "New Users by Day", | |
| 106 | + description: "Track new user registrations grouped by day", | |
| 107 | + statement: <<~SQL | |
| 108 | + SELECT#{' '} | |
| 109 | + DATE(created_at) as registration_date, | |
| 110 | + COUNT(*) as new_users, | |
| 111 | + COUNT(CASE WHEN google_user_id IS NOT NULL THEN 1 END) as google_signups, | |
| 112 | + COUNT(CASE WHEN google_user_id IS NULL THEN 1 END) as email_signups | |
| 113 | + FROM users#{' '} | |
| 114 | + WHERE created_at >= date('now', '-30 days') | |
| 115 | + GROUP BY DATE(created_at) | |
| 116 | + ORDER BY registration_date DESC | |
| 117 | + SQL | |
| 118 | + } | |
| 119 | +] | |
| 120 | + | |
| 121 | +blazer_queries.each do |query_attrs| | |
| 122 | + query = Blazer::Query.find_or_initialize_by(name: query_attrs[:name]) | |
| 123 | + query.assign_attributes(query_attrs.merge(data_source: "main")) | |
| 124 | + | |
| 125 | + if query.save | |
| 126 | + puts "โ Created/Updated Blazer query: #{query.name}" | |
| 127 | + else | |
| 128 | + puts "โ Error creating query #{query_attrs[:name]}: #{query.errors.full_messages.join(', ')}" | |
| 129 | + end | |
| 130 | +end | |
| 131 | + | |
| 67 | 132 | puts "Seed data loaded successfully!" |
| @@ -0,0 +1,88 @@ | ||
| 1 | +require 'rails_helper' | |
| 2 | + | |
| 3 | +RSpec.describe "Blazer Authentication", type: :request do | |
| 4 | + let(:admin_user) { User.create!(email_address: "admin@example.com") } | |
| 5 | + let(:regular_user) { User.create!(email_address: "user@example.com") } | |
| 6 | + let(:admin_session) { Session.create!(user: admin_user) } | |
| 7 | + let(:regular_session) { Session.create!(user: regular_user) } | |
| 8 | + | |
| 9 | + before do | |
| 10 | + # Set up admin user IDs in config | |
| 11 | + Rails.application.config.admin_user_ids = [ admin_user.id ] | |
| 12 | + end | |
| 13 | + | |
| 14 | + | |
| 15 | + describe "accessing blazer routes" do | |
| 16 | + context "without authentication" do | |
| 17 | + it "denies access to blazer dashboard" do | |
| 18 | + get "/blazer" | |
| 19 | + expect(response).to have_http_status(403) | |
| 20 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 21 | + end | |
| 22 | + | |
| 23 | + it "denies access to blazer queries" do | |
| 24 | + get "/blazer/queries" | |
| 25 | + expect(response).to have_http_status(403) | |
| 26 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 27 | + end | |
| 28 | + end | |
| 29 | + | |
| 30 | + context "with regular user authentication" do | |
| 31 | + let(:signed_cookie_value) do | |
| 32 | + Rails.application.message_verifier("signed cookie").generate(regular_session.id) | |
| 33 | + end | |
| 34 | + | |
| 35 | + it "denies access to blazer dashboard for non-admin users" do | |
| 36 | + get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 37 | + expect(response).to have_http_status(403) | |
| 38 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 39 | + end | |
| 40 | + | |
| 41 | + it "denies access to blazer queries for non-admin users" do | |
| 42 | + get "/blazer/queries", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 43 | + expect(response).to have_http_status(403) | |
| 44 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 45 | + end | |
| 46 | + end | |
| 47 | + | |
| 48 | + context "with admin user authentication" do | |
| 49 | + let(:signed_cookie_value) do | |
| 50 | + Rails.application.message_verifier("signed cookie").generate(admin_session.id) | |
| 51 | + end | |
| 52 | + | |
| 53 | + it "allows access to blazer dashboard for admin users" do | |
| 54 | + get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 55 | + expect(response).to have_http_status(200) | |
| 56 | + end | |
| 57 | + | |
| 58 | + it "allows access to blazer queries for admin users" do | |
| 59 | + get "/blazer/queries", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 60 | + # Blazer queries page redirects to queries list, which is expected behavior | |
| 61 | + expect([ 200, 302 ]).to include(response.status) | |
| 62 | + end | |
| 63 | + end | |
| 64 | + | |
| 65 | + context "with invalid session" do | |
| 66 | + let(:signed_cookie_value) do | |
| 67 | + Rails.application.message_verifier("signed cookie").generate("invalid_session_id") | |
| 68 | + end | |
| 69 | + | |
| 70 | + it "denies access with invalid session" do | |
| 71 | + get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 72 | + expect(response).to have_http_status(403) | |
| 73 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 74 | + end | |
| 75 | + end | |
| 76 | + | |
| 77 | + context "with deleted session" do | |
| 78 | + it "denies access when session is deleted" do | |
| 79 | + session = Session.create!(user: admin_user) | |
| 80 | + signed_cookie_value = Rails.application.message_verifier("signed cookie").generate(session.id) | |
| 81 | + session.destroy | |
| 82 | + get "/blazer", headers: { "Cookie" => "session_id=#{signed_cookie_value}" } | |
| 83 | + expect(response).to have_http_status(403) | |
| 84 | + expect(response.body).to include("Access denied - Admin privileges required") | |
| 85 | + end | |
| 86 | + end | |
| 87 | + end | |
| 88 | +end |