diff --git a/backend/app/controllers/admin/analytics_controller.rb b/backend/app/controllers/admin/analytics_controller.rb index 34f7a74..03e0ca3 100644 --- a/backend/app/controllers/admin/analytics_controller.rb +++ b/backend/app/controllers/admin/analytics_controller.rb @@ -8,6 +8,7 @@ module Admin to: parse_date(params[:to]) || Time.zone.today, device: params[:device].presence } + @analytics_preview_active = analytics_preview_active? scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) @@ -41,15 +42,23 @@ module Admin @page_path = params[:page_path].to_s redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank? + from = parse_date(params[:from]) || 7.days.ago.to_date + to = parse_date(params[:to]) || Time.zone.today + @device_tab_stats = device_tab_stats(@page_path, from, to) + device = resolve_heatmap_device(@device_tab_stats, params[:device].presence) + @filters = { - from: parse_date(params[:from]) || 7.days.ago.to_date, - to: parse_date(params[:to]) || Time.zone.today, - device: params[:device].presence, + from: from, + to: to, + device: device, layer: params[:layer].to_s } - cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) - cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + cells = AnalyticsPageCell.where( + page_path: @page_path, + day: @filters[:from]..@filters[:to], + device: @filters[:device] + ) @click_total = cells.sum(:click_count) @move_total = cells.sum(:move_count) @@ -67,8 +76,11 @@ module Admin @max_weight = @cells.values.max.to_i @total_points = @cells.values.sum - stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) - stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + stats = AnalyticsPageStat.where( + page_path: @page_path, + day: @filters[:from]..@filters[:to], + device: @filters[:device] + ) @pageviews = stats.sum(:pageview_count) @scroll_samples = stats.sum(:scroll_samples) @scroll_sum = stats.sum(:scroll_sum_pct) @@ -78,8 +90,30 @@ module Admin @snapshot = find_snapshot(@page_path, @filters[:device]) end + def preview_enable + Analytics::Suppress.enable!(cookies) + redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled") + end + + def preview_disable + Analytics::Suppress.disable!(cookies) + redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled") + end + private + def analytics_preview_active? + Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME]) + end + + def preview_return_to(value) + path = value.to_s.strip + return root_path if path.blank? + return path if path.start_with?("/") && !path.start_with?("//") + + admin_analytics_path + end + def parse_date(value) return nil if value.blank? @@ -88,14 +122,43 @@ module Admin nil end - def find_snapshot(page_path, device) - scope = AnalyticsPageSnapshot.where(page_path: page_path) - if device.present? && AnalyticsEvent::DEVICES.include?(device) - snap = scope.find_by(device: device) - return snap if snap&.image&.attached? + def device_tab_stats(page_path, from, to) + cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to) + .group(:device) + .pluck( + :device, + Arel.sql("SUM(click_count)"), + Arel.sql("SUM(move_count)") + ) + cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] } + + pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to) + .group(:device) + .sum(:pageview_count) + + AnalyticsEvent::DEVICES.index_with do |device| + cells = cell_by_device[device] || { clicks: 0, moves: 0 } + cells.merge(pageviews: pageview_totals[device].to_i) + end + end + + def resolve_heatmap_device(tab_stats, requested) + if requested.present? && AnalyticsEvent::DEVICES.include?(requested) + return requested end - scope.order(captured_at: :desc).detect { |s| s.image.attached? } + AnalyticsEvent::DEVICES.max_by do |device| + stats = tab_stats[device] + stats[:clicks] + stats[:moves] + stats[:pageviews] + end + end + + def find_snapshot(page_path, device) + return nil unless AnalyticsEvent::DEVICES.include?(device) + + AnalyticsPageSnapshot.where(page_path: page_path, device: device) + .order(captured_at: :desc) + .detect { |snapshot| snapshot.image.attached? } end end end diff --git a/backend/app/controllers/admin/cost_entries_controller.rb b/backend/app/controllers/admin/cost_entries_controller.rb new file mode 100644 index 0000000..4b199a0 --- /dev/null +++ b/backend/app/controllers/admin/cost_entries_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Admin + class CostEntriesController < Admin::BaseController + before_action :set_entry, only: %i[edit update destroy] + + def new + @entry = PlatformCostEntry.new( + month: parse_month(params[:month]) || Time.zone.today.beginning_of_month, + label: PlatformCostEntry::DEFAULT_LABEL + ) + end + + def create + @entry = PlatformCostEntry.new(entry_attributes) + if @entry.save + redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created") + else + flash.now[:alert] = @entry.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @entry.update(entry_attributes) + redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated") + else + flash.now[:alert] = @entry.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy + month = @entry.month + @entry.destroy! + redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed") + end + + private + + def set_entry + @entry = PlatformCostEntry.find(params[:id]) + end + + def entry_attributes + attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes) + if attrs[:amount_euros].present? + attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros)) + end + attrs[:notes] = nil if attrs[:notes].blank? + attrs + end + + def parse_month(value) + return nil if value.blank? + + Date.strptime(value.to_s, "%Y-%m").beginning_of_month + rescue ArgumentError, TypeError + nil + end + + def month_param(date) + date.strftime("%Y-%m") + end + end +end diff --git a/backend/app/controllers/admin/costs_controller.rb b/backend/app/controllers/admin/costs_controller.rb new file mode 100644 index 0000000..717016b --- /dev/null +++ b/backend/app/controllers/admin/costs_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Admin + class CostsController < Admin::BaseController + def index + @month = parse_month(params[:month]) || Time.zone.today.beginning_of_month + analytics = Admin::CostAnalytics.new(month: @month) + @summary = analytics.summary + @trend = analytics.trend + @clubs = analytics.club_breakdown + @entries = PlatformCostEntry.for_month(@month).ordered + @month_options = month_options(@month) + end + + private + + def parse_month(value) + return nil if value.blank? + + Date.strptime(value.to_s, "%Y-%m").beginning_of_month + rescue ArgumentError, TypeError + nil + end + + def month_options(selected) + start = selected - 23.months + (0..23).map { |i| start + i.months }.reverse + end + end +end diff --git a/backend/app/controllers/analytics/events_controller.rb b/backend/app/controllers/analytics/events_controller.rb index 33b8bba..765637d 100644 --- a/backend/app/controllers/analytics/events_controller.rb +++ b/backend/app/controllers/analytics/events_controller.rb @@ -2,7 +2,13 @@ module Analytics class EventsController < ActionController::API + include ActionController::Cookies + def create + if analytics_suppressed? + return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted + end + payload = parse_payload result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call @@ -15,6 +21,10 @@ module Analytics private + def analytics_suppressed? + Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME]) + end + def parse_payload body = request.request_parameters return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array) diff --git a/backend/app/controllers/analytics/snapshots_controller.rb b/backend/app/controllers/analytics/snapshots_controller.rb index 575f1e9..8c1c9ce 100644 --- a/backend/app/controllers/analytics/snapshots_controller.rb +++ b/backend/app/controllers/analytics/snapshots_controller.rb @@ -2,7 +2,13 @@ module Analytics class SnapshotsController < ActionController::API + include ActionController::Cookies + def create + if analytics_suppressed? + return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted + end + result = Analytics::SnapshotIngest.new( path: params[:path] || params[:page_path], device: params[:device], @@ -17,5 +23,11 @@ module Analytics render json: { ok: true, skipped: result.skipped }, status: :accepted end + + private + + def analytics_suppressed? + Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME]) + end end end diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb index f9a32e9..a260730 100644 --- a/backend/app/helpers/admin_helper.rb +++ b/backend/app/helpers/admin_helper.rb @@ -1,4 +1,21 @@ module AdminHelper + def format_euros(cents, precision: 2) + return I18n.t("admin.common.dash") if cents.nil? + + format("%.*f €", precision, cents.to_f / 100.0) + end + + def format_hours(hours) + return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0 + + total_minutes = (hours.to_f * 60).round + format_duration_minutes(total_minutes) + end + + def admin_month_label(month) + I18n.l(month, format: "%B %Y") + end + def format_bytes(bytes) return "—" if bytes.nil? diff --git a/backend/app/models/platform_cost_entry.rb b/backend/app/models/platform_cost_entry.rb new file mode 100644 index 0000000..d0ac5a5 --- /dev/null +++ b/backend/app/models/platform_cost_entry.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PlatformCostEntry < ApplicationRecord + DEFAULT_LABEL = "Piattaforma produzione" + + validates :month, presence: true + validates :label, presence: true, length: { maximum: 120 } + validates :amount_cents, numericality: { only_integer: true, greater_than: 0 } + + before_validation :normalize_month + + scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) } + scope :ordered, -> { order(month: :desc, created_at: :desc) } + + def month=(value) + if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/) + super(Date.strptime(value, "%Y-%m")) + else + super(value) + end + end + + def amount_euros + amount_cents.to_f / 100.0 + end + + private + + def normalize_month + return if month.blank? + + parsed = + case month + when Date + month + when Time, ActiveSupport::TimeWithZone + month.to_date + when String + if month.match?(/\A\d{4}-\d{2}\z/) + Date.strptime(month, "%Y-%m") + else + Date.parse(month) + end + else + Date.parse(month.to_s) + end + self.month = parsed.beginning_of_month + rescue ArgumentError, TypeError + errors.add(:month, :invalid) + end +end diff --git a/backend/app/services/admin/cost_analytics.rb b/backend/app/services/admin/cost_analytics.rb new file mode 100644 index 0000000..66e30f6 --- /dev/null +++ b/backend/app/services/admin/cost_analytics.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +module Admin + class CostAnalytics + TREND_MONTHS = 12 + + def initialize(month:) + @month = month.to_date.beginning_of_month + end + + def summary + build_period(@month) + end + + def trend(months: TREND_MONTHS) + start_month = @month - (months - 1).months + months_list = (0...months).map { |i| start_month + i.months } + months_list.map { |m| build_period(m) } + end + + def club_breakdown + range = month_range(@month) + rows = session_rows(range) + total_secs = rows.sum { |row| row.total_secs.to_i } + total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents) + revenue_by_club = revenue_by_club(range) + storage_by_club = storage_by_club_index + club_ids = rows.map(&:club_id) + clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id) + + rows.map do |row| + club = clubs[row.club_id] + secs = row.total_secs.to_i + hours = secs / 3600.0 + sessions_count = row.sessions_count.to_i + share = total_secs.positive? ? secs.to_f / total_secs : 0.0 + allocated_cents = (total_cost_cents * share).round + revenue_cents = revenue_by_club[row.club_id].to_i + storage_bytes = storage_by_club[row.club_id].to_i + + { + club_id: row.club_id, + club_name: row.club_name, + plan_slug: club&.subscription&.plan&.slug, + sessions: sessions_count, + hours: hours.round(2), + hours_share_pct: (share * 100).round(1), + allocated_cost_cents: allocated_cents, + revenue_cents: revenue_cents, + margin_cents: revenue_cents - allocated_cents, + cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil, + cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil, + storage_bytes: storage_bytes + } + end.sort_by { |row| [-row[:hours], row[:club_name]] } + end + + private + + def build_period(month) + range = month_range(month) + cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents) + sessions_scope = ended_sessions.where(ended_at: range) + total_secs = sessions_scope.sum(:total_duration_secs).to_i + hours = total_secs / 3600.0 + sessions = sessions_scope.count + clubs_active = distinct_active_clubs(range) + revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents) + storage_bytes = Recording.ready.sum(:byte_size).to_i + storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0 + + kpis = compute_kpis( + cost_cents: cost_cents, + hours: hours, + sessions: sessions, + clubs_active: clubs_active, + revenue_cents: revenue_cents, + storage_gb: storage_gb + ) + + { + month: month, + cost_cents: cost_cents, + sessions: sessions, + hours: hours.round(2), + clubs_active: clubs_active, + revenue_cents: revenue_cents, + storage_bytes: storage_bytes, + **kpis + } + end + + def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:) + margin_cents = revenue_cents.to_i - cost_cents.to_i + margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil + hours_f = hours.to_f + sessions_i = sessions.to_i + clubs_i = clubs_active.to_i + storage_f = storage_gb.to_f + cost_i = cost_cents.to_i + + { + cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil, + cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil, + cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil, + revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil, + cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil, + margin_cents: margin_cents, + margin_pct: margin_pct + } + end + + def month_range(month) + month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day + end + + def ended_sessions + StreamSession.where(status: "ended") + end + + def distinct_active_clubs(range) + ended_sessions + .where(ended_at: range) + .joins(match: { team: :club }) + .distinct + .count("clubs.id") + end + + def session_rows(range) + ended_sessions + .where(ended_at: range) + .joins(match: { team: :club }) + .group("clubs.id", "clubs.name") + .select( + "clubs.id AS club_id", + "clubs.name AS club_name", + "COUNT(stream_sessions.id) AS sessions_count", + "SUM(stream_sessions.total_duration_secs) AS total_secs" + ) + end + + def revenue_by_club(range) + Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents) + end + + def storage_by_club_index + Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size) + end + end +end diff --git a/backend/app/services/analytics/suppress.rb b/backend/app/services/analytics/suppress.rb new file mode 100644 index 0000000..82df169 --- /dev/null +++ b/backend/app/services/analytics/suppress.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Analytics + module Suppress + COOKIE_NAME = "mltv_analytics_suppress" + COOKIE_MAX_AGE = 7 * 24 * 60 * 60 + + module_function + + def active?(cookie_value) + cookie_value.to_s == "1" + end + + def enable!(cookie_jar) + cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now) + end + + def disable!(cookie_jar) + cookie_jar.delete( + COOKIE_NAME, + path: "/", + same_site: :lax, + secure: cookie_secure? + ) + end + + def cookie_options(value:, expires:) + { + value: value, + expires: expires, + path: "/", + httponly: true, + same_site: :lax, + secure: cookie_secure? + } + end + + def cookie_secure? + Rails.application.config.force_ssl || Rails.env.production? + end + end +end diff --git a/backend/app/views/admin/analytics/index.html.erb b/backend/app/views/admin/analytics/index.html.erb index ba91254..80f2226 100644 --- a/backend/app/views/admin/analytics/index.html.erb +++ b/backend/app/views/admin/analytics/index.html.erb @@ -5,6 +5,32 @@

<%= t("admin.analytics.index.lead") %>

+
+

<%= t("admin.analytics.preview.title") %>

+

<%= t("admin.analytics.preview.lead") %>

+ <% if @analytics_preview_active %> +

<%= t("admin.analytics.preview.active") %>

+
+ <%= button_to t("admin.analytics.preview.disable"), + admin_analytics_preview_path, + method: :delete, + class: "admin-btn admin-btn--outline admin-btn--sm" %> + <%= link_to t("admin.analytics.preview.open_site"), + root_path, + class: "admin-btn admin-btn--primary admin-btn--sm", + target: "_blank", + rel: "noopener" %> +
+ <% else %> +
+ <%= button_to t("admin.analytics.preview.enable"), + admin_analytics_preview_path(return_to: root_path), + method: :post, + class: "admin-btn admin-btn--primary admin-btn--sm" %> +
+ <% end %> +
+
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
@@ -58,8 +84,9 @@ <%= avg %>% <%= row[:max_scroll] %>% - <%= link_to t("admin.analytics.index.heatmap"), - admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> + <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %> + <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %> + <%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %> <% end %> diff --git a/backend/app/views/admin/analytics/show.html.erb b/backend/app/views/admin/analytics/show.html.erb index 4c83ff9..76395f1 100644 --- a/backend/app/views/admin/analytics/show.html.erb +++ b/backend/app/views/admin/analytics/show.html.erb @@ -3,7 +3,7 @@

- <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> + <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>

<%= t("admin.analytics.show.title") %>

<%= @page_path %>

@@ -11,8 +11,32 @@
+

+ <%= t("admin.analytics.show.device_tabs_label") %> +

+
+ <% AnalyticsEvent::DEVICES.each do |device| %> + <% stats = @device_tab_stats[device] %> + <% active = @filters[:device] == device %> + <%= link_to admin_analytics_page_path( + page_path: @page_path, + from: @filters[:from], + to: @filters[:to], + device: device, + layer: @filters[:layer] + ), + class: "admin-locale-tab #{'is-active' if active}", + role: "tab", + "aria-selected": active do %> + <%= t("admin.analytics.devices.#{device}") %> + <%= stats[:pageviews] %> + <% end %> + <% end %> +
+ <%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %> <%= hidden_field_tag :page_path, @page_path %> + <%= hidden_field_tag :device, @filters[:device] %>
-