diff --git a/app/controllers/insights_controller.rb b/app/controllers/insights_controller.rb index 7f339e0fb..9ba67c9fa 100644 --- a/app/controllers/insights_controller.rb +++ b/app/controllers/insights_controller.rb @@ -1,4 +1,5 @@ class InsightsController < ApplicationController + before_action :require_preview_features! before_action :set_insight, only: %i[dismiss undismiss] def index diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index da4256065..e1dc1a5d7 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -51,7 +51,9 @@ class PagesController < ApplicationController @cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency) @outflows_data = build_outflows_donut_data(net_totals) - @feed_insights = Current.family.insights.visible.ordered.limit(3) + # Preview-gated: skip the query outright rather than loading rows the + # section won't be built from. + @feed_insights = preview_features_enabled? ? Current.family.insights.visible.ordered.limit(3) : Insight.none @money_flow_accounts = income_statement.eligible_accounts @money_flow_month = money_flow_month_param @@ -118,17 +120,27 @@ class PagesController < ApplicationController end end + # Preview-gated, and omitted from the section list entirely rather than + # left in it with `visible: false`. Dropping it here means the two + # downstream behaviors fall out for free: the saved-order lookup finds + # nothing to map, and the insights_feed unshift special-case never fires. + def insights_feed_section + return nil unless preview_features_enabled? + + { + key: "insights_feed", + title: "pages.dashboard.insights_feed.title", + partial: "pages/dashboard/insights_feed", + layout: section_layout("insights_feed"), + locals: { insights: @feed_insights }, + visible: @feed_insights.any?, + collapsible: true + } + end + def build_dashboard_sections all_sections = [ - { - key: "insights_feed", - title: "pages.dashboard.insights_feed.title", - partial: "pages/dashboard/insights_feed", - layout: section_layout("insights_feed"), - locals: { insights: @feed_insights }, - visible: @feed_insights.any?, - collapsible: true - }, + insights_feed_section, { key: "cashflow_sankey", title: "pages.dashboard.cashflow_sankey.title", @@ -183,7 +195,7 @@ class PagesController < ApplicationController visible: @accounts.any?, collapsible: true } - ] + ].compact # Order sections according to user preference section_order = Current.user.dashboard_section_order diff --git a/app/jobs/generate_insights_job.rb b/app/jobs/generate_insights_job.rb index 4155764b1..311071950 100644 --- a/app/jobs/generate_insights_job.rb +++ b/app/jobs/generate_insights_job.rb @@ -13,8 +13,14 @@ class GenerateInsightsJob < ApplicationJob end private + # Insights are a preview feature, so the nightly sweep only visits families + # with an opted-in member. Unlike a job that moves existing data around, + # this one manufactures data per family — seven generators over the income + # statement and balance sheet, plus paid LLM narration — so running it for + # families who can't see the result is pure waste. Scoped in SQL rather + # than checked per family to keep the fan-out a single indexed query. def fan_out - Family.find_each do |family| + Family.with_preview_features.find_each do |family| GenerateInsightsJob.perform_later(family_id: family.id) rescue => e Rails.logger.error("Failed to enqueue insight generation for family #{family.id}: #{e.message}") @@ -25,6 +31,11 @@ class GenerateInsightsJob < ApplicationJob family = Family.find_by(id: family_id) return unless family return if family.accounts.none? + # Also checked here, not just at the fan-out: this path is reachable + # directly via perform_later(family_id:) from the refresh action and the + # console. Returning above the lock means a gated family skips the + # broadcast below too, not just the generation. + return unless family.preview_features_enabled? with_advisory_lock(family_id) do I18n.with_locale(family.locale) do diff --git a/app/models/family.rb b/app/models/family.rb index ddd11c4d6..c5e7602c0 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -116,6 +116,27 @@ class Family < ApplicationRecord has_many :recurring_transactions, dependent: :destroy has_many :insights, dependent: :destroy + # Families with at least one opted-in member. Lets a job filter in one + # indexed query rather than loading every family and asking each in Ruby. + scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) } + + # Family-level rollup of the per-user preview flag, for callers that run + # without a Current.user (the nightly insights job). Preview access is a + # personal preference but the data it produces is family-scoped, so one + # opted-in member is enough to generate for the family. + # + # EXISTS rather than `users.any?(&:preview_features_enabled?)`: the job asks + # this once per family, and the block form would load and instantiate every + # member just to answer a boolean. + # + # Never gate UI on this — visibility is per-user, and this answers "somebody + # in the household opted in", so a view using it would show the feature to a + # user who explicitly opted out. Use the PreviewGateable helper (Current.user) + # for anything a person sees. + def preview_features_enabled? + users.with_preview_features.exists? + end + validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } validates :month_start_day, inclusion: { in: 1..28 } diff --git a/app/models/user.rb b/app/models/user.rb index 8938c2d75..efc435b3b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,6 +56,16 @@ class User < ApplicationRecord normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } enum :role, { guest: "guest", member: "member", admin: "admin", super_admin: "super_admin" }, validate: true + + # SQL counterpart to #preview_features_enabled?, for callers that filter + # users (or their families) in one query instead of loading and iterating. + # The `@>` containment operator uses index_users_on_preferences (GIN) and + # matches only a JSON boolean true, so it agrees with that predicate's + # strict `== true` — a stray "yes" enables neither. + scope :with_preview_features, -> { + where("preferences @> ?", { preview_features_enabled: true }.to_json) + } + attribute :ui_layout, :string enum :ui_layout, { dashboard: "dashboard", intro: "intro" }, validate: true, prefix: true diff --git a/app/views/insights/index.html.erb b/app/views/insights/index.html.erb index 3b360f869..a28d6d7c2 100644 --- a/app/views/insights/index.html.erb +++ b/app/views/insights/index.html.erb @@ -3,7 +3,10 @@
<%= t(".subtitle") %>
<%= t("insights.feed.header_new") %>·<%= new_count %>
- <% else %> -<%= t("insights.feed.header") %>
- <% end %> +<%= t("insights.feed.header_new") %>·<%= new_count %>
+ <% else %> +<%= t("insights.feed.header") %>
+ <% end %> + <%= render DS::Pill.new(label: t("shared.preview")) %> +