From f0058724764d437b77cb876b3f0c051a22651f44 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 00:08:52 +0900 Subject: [PATCH 01/19] =?UTF-8?q?Add:=20development=E7=92=B0=E5=A2=83?= =?UTF-8?q?=E3=81=AEadmin=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AB?= =?UTF-8?q?=E5=BC=B7=E5=88=B6Pro=E3=83=A2=E3=83=BC=E3=83=89=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pro機能Epicリリース前レビュー(issue #445)で判明した、admin向け強制Proモード(PRD-01 US-08)の未実装を解消。 users.is_adminを追加し、Subscription#pro_active?でdevelopment環境かつadminなら常にtrueを返すようにする。 rails pro:make_admin[email]でdevelopment限定にis_adminを付与できる。 --- app/models/subscription.rb | 2 ++ .../20260728010001_add_is_admin_to_users.rb | 5 ++++ db/schema.rb | 3 ++- lib/tasks/pro.rake | 10 ++++++++ spec/lib/tasks/pro_rake_spec.rb | 25 +++++++++++++++++++ spec/models/subscription_spec.rb | 25 +++++++++++++++++++ 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260728010001_add_is_admin_to_users.rb diff --git a/app/models/subscription.rb b/app/models/subscription.rb index e92e6db4..6ef80d7e 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -30,8 +30,10 @@ class Subscription < ApplicationRecord # Pro 機能が利用可能か。 # 期限内かつ status が trial / active / cancelled / billing_issue のとき true。 + # development 環境の admin ユーザーは課金なしで常に Pro 扱いにする(強制 Pro モード)。 # @return [Boolean] def pro_active? + return true if Rails.env.development? && user.is_admin? return false unless PRO_ACTIVE_STATUSES.include?(status) expires_at.nil? || expires_at > Time.current diff --git a/db/migrate/20260728010001_add_is_admin_to_users.rb b/db/migrate/20260728010001_add_is_admin_to_users.rb new file mode 100644 index 00000000..613b7c69 --- /dev/null +++ b/db/migrate/20260728010001_add_is_admin_to_users.rb @@ -0,0 +1,5 @@ +class AddIsAdminToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :is_admin, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 2e4925a9..eebe7517 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_07_21_150618) do +ActiveRecord::Schema[7.1].define(version: 2026_07_28_010001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1059,6 +1059,7 @@ t.string "suspended_reason" t.boolean "is_private", default: false, null: false t.datetime "last_management_notice_read_at" + t.boolean "is_admin", default: false, null: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["deleted_at"], name: "index_users_on_deleted_at" t.index ["email"], name: "index_users_on_email", unique: true diff --git a/lib/tasks/pro.rake b/lib/tasks/pro.rake index 182234ad..fc620fb2 100644 --- a/lib/tasks/pro.rake +++ b/lib/tasks/pro.rake @@ -8,4 +8,14 @@ namespace :pro do task pro_expiring_reminder: :environment do ProExpiringReminderJob.perform_now end + + desc '指定メールアドレスのユーザーを admin にする(development 限定、強制 Pro モード用)' + task :make_admin, [:email] => :environment do |_task, args| + raise 'production では実行できません' if Rails.env.production? + raise 'Usage: rails pro:make_admin[email@example.com]' if args.email.blank? + + user = User.find_by!(email: args.email) + user.update!(is_admin: true) + puts "#{user.email} を admin にしました(development 環境で強制 Pro モードが有効になります)" + end end diff --git a/spec/lib/tasks/pro_rake_spec.rb b/spec/lib/tasks/pro_rake_spec.rb index 50f643aa..25dc8b12 100644 --- a/spec/lib/tasks/pro_rake_spec.rb +++ b/spec/lib/tasks/pro_rake_spec.rb @@ -30,4 +30,29 @@ expect(ProExpiringReminderJob).to have_received(:perform_now) end end + + describe 'pro:make_admin' do + let(:task) { Rake::Task['pro:make_admin'] } + let(:user) { create(:user, is_admin: false) } + + before { task.reenable } + + it '指定したメールアドレスのユーザーを is_admin: true にする' do + task.invoke(user.email) + expect(user.reload.is_admin).to be true + end + + it 'email が空のときは例外を送出する' do + expect { task.invoke }.to raise_error(/Usage/) + end + + it '存在しないメールアドレスのときは例外を送出する' do + expect { task.invoke('unknown@example.com') }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'production 環境では例外を送出する' do + allow(Rails.env).to receive(:production?).and_return(true) + expect { task.invoke(user.email) }.to raise_error('production では実行できません') + end + end end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index bc3ef9f4..e72c5521 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -46,6 +46,31 @@ subscription = build(:subscription, :active, expires_at: nil) expect(subscription.pro_active?).to be true end + + context 'when development 環境の admin ユーザー(強制 Pro モード)' do + it 'returns true for a free status admin user in development' do + allow(Rails.env).to receive(:development?).and_return(true) + admin = create(:user, is_admin: true) + subscription = build(:subscription, :free, owner: admin) + + expect(subscription.pro_active?).to be true + end + + it 'returns false for a free status admin user outside development' do + admin = create(:user, is_admin: true) + subscription = build(:subscription, :free, owner: admin) + + expect(subscription.pro_active?).to be false + end + + it 'returns false for a free status non-admin user in development' do + allow(Rails.env).to receive(:development?).and_return(true) + non_admin = create(:user, is_admin: false) + subscription = build(:subscription, :free, owner: non_admin) + + expect(subscription.pro_active?).to be false + end + end end describe '#in_trial?' do From ae9d2db256b62b642e00155b5fcf414da7b0e16b Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 00:10:08 +0900 Subject: [PATCH 02/19] =?UTF-8?q?Fix:=20goal=E6=9B=B4=E6=96=B0API=E3=81=A7?= =?UTF-8?q?metric=5Fkey/comparison=5Ftype/practice=5Fmenu=5Fid=E3=82=92?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E4=B8=8D=E5=8F=AF=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_paramsの許可リストに残っていたため、作成後に指標を差し替えられてしまい フロント(mobile new.tsx)の「作成後は指標変更不可」という前提と矛盾していた。 指標を差し替えると既存のtarget_value/manual_current_valueが無意味になるため、 period_type/season_idと同様に更新不可にする。 --- app/controllers/api/v2/goals_controller.rb | 6 +++--- spec/requests/api/v2/goals_spec.rb | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/v2/goals_controller.rb b/app/controllers/api/v2/goals_controller.rb index a996acc7..06d5b594 100644 --- a/app/controllers/api/v2/goals_controller.rb +++ b/app/controllers/api/v2/goals_controller.rb @@ -77,11 +77,11 @@ def goal_params :custom_metric_label, :custom_unit, :manual_current_value) end - # 更新では種類(period_type / season_id)を変更させない。 + # 更新では種類(period_type / season_id)と指標(metric_key / comparison_type / practice_menu_id)を変更させない。 + # 指標を差し替えると既存の target_value / manual_current_value が新指標に対して無意味になるため。 def update_params params.require(:goal).permit(:title, :month_start, :deadline, - :metric_key, :target_value, :comparison_type, :practice_menu_id, - :custom_metric_label, :custom_unit, :manual_current_value) + :target_value, :custom_metric_label, :custom_unit, :manual_current_value) end end end diff --git a/spec/requests/api/v2/goals_spec.rb b/spec/requests/api/v2/goals_spec.rb index 6b6e1260..9f3f5051 100644 --- a/spec/requests/api/v2/goals_spec.rb +++ b/spec/requests/api/v2/goals_spec.rb @@ -168,6 +168,20 @@ def make_pro(target) expect(goal.reload.period_type).to eq('monthly') expect(goal.title).to eq('変更') end + + it '更新で指標(metric_key / comparison_type / practice_menu_id)は変更できない' do + goal = create(:goal, user:, metric_key: 'practice_days', comparison_type: 'greater_than', practice_menu_id: nil) + other_menu = create(:practice_menu, user:) + patch "/api/v2/goals/#{goal.id}", + params: { goal: { metric_key: 'batting_average', comparison_type: 'less_than', practice_menu_id: other_menu.id } }, + headers: auth_headers_for(user) + expect(response).to have_http_status(:ok) + aggregate_failures do + expect(goal.reload.metric_key).to eq('practice_days') + expect(goal.comparison_type).to eq('greater_than') + expect(goal.practice_menu_id).to be_nil + end + end end describe '定性目標(qualitative)' do From 86ab4c33a855b653ba2589e8598e134b86b5f924 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 13:28:52 +0900 Subject: [PATCH 03/19] =?UTF-8?q?Fix:=20=E3=82=B7=E3=83=BC=E3=82=BA?= =?UTF-8?q?=E3=83=B3=E7=B2=92=E5=BA=A6=E6=8E=A8=E7=A7=BB=E3=81=A7season=5F?= =?UTF-8?q?id=E7=B5=9E=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A8=E4=BD=B5?= =?UTF-8?q?=E7=94=A8=E3=81=99=E3=82=8B=E3=81=A81=E3=82=B7=E3=83=BC?= =?UTF-8?q?=E3=82=BA=E3=83=B3=E3=81=AB=E7=B8=AE=E9=80=80=E3=81=99=E3=82=8B?= =?UTF-8?q?=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit granularity=season はシーズン跨ぎで全シーズンを比較する機能だが、既存の season_id フィルタが同時に渡されると単一シーズンに絞り込まれてしまい、 比較の意味が失われていた。season粒度選択時はseason_idを無視するようにする。 --- app/controllers/api/v2/stats_controller.rb | 11 ++++++++++- .../api/v2/stats_batting_trend_season_spec.rb | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v2/stats_controller.rb b/app/controllers/api/v2/stats_controller.rb index 54a73c7c..3535e43f 100644 --- a/app/controllers/api/v2/stats_controller.rb +++ b/app/controllers/api/v2/stats_controller.rb @@ -69,7 +69,7 @@ def batting_trend end render json: Stats::BattingTrendAggregator.new( - **aggregator_params, granularity: params[:granularity] + **batting_trend_params, granularity: params[:granularity] ).call end @@ -109,6 +109,15 @@ def aggregator_params } end + # granularity=season はシーズン跨ぎで全シーズンを比較する機能のため、 + # season_id による単一シーズン絞り込みと併用すると1シーズンに縮退してしまう。 + # season 粒度選択時は season_id を無視する。 + def batting_trend_params + return aggregator_params unless params[:granularity].to_s == 'season' + + aggregator_params.merge(season_id: nil) + end + # batting / pitching テーブル用は period (mode) を追加で受け取り、 # match_type は使わない(テーブルサービスのインターフェースに合わせる)。 def table_params diff --git a/spec/requests/api/v2/stats_batting_trend_season_spec.rb b/spec/requests/api/v2/stats_batting_trend_season_spec.rb index 3a2a0d39..a8ac1b38 100644 --- a/spec/requests/api/v2/stats_batting_trend_season_spec.rb +++ b/spec/requests/api/v2/stats_batting_trend_season_spec.rb @@ -34,6 +34,22 @@ def make_pro(target) expect(body['granularity']).to eq('season') expect(body['points'].first['label']).to eq('2026春') end + + it '既存のseason_id絞り込みと併用しても全シーズンを返す(season_idは無視される)' do + season1 = create(:season, user:, name: '2026春') + season2 = create(:season, user:, name: '2026夏') + game1 = create(:game_result, user:, season: season1) + game2 = create(:game_result, user:, season: season2) + create(:batting_average, game_result: game1, user:, at_bats: 4, hit: 2, total_bases: 2) + create(:batting_average, game_result: game2, user:, at_bats: 4, hit: 1, total_bases: 1) + + get '/api/v2/stats/batting_trend', + params: { granularity: 'season', season_id: season1.id }, + headers: auth_headers_for(user) + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['points'].pluck('label')).to contain_exactly('2026春', '2026夏') + end end end end From 662ead936a9d63a2787e13650e384b077a8bcb7d Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 13:39:09 +0900 Subject: [PATCH 04/19] =?UTF-8?q?Add:=20/pro/sync=E3=82=92RevenueCat=20RES?= =?UTF-8?q?T=20API=E3=81=A8=E3=81=AE=E5=AE=9F=E5=90=8C=E6=9C=9F=E3=81=AB?= =?UTF-8?q?=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまではlast_synced_atを更新するだけのスタブだったため、Webhookの取りこぼし・ 配信遅延時に「同期更新」ボタンが実質何もしていなかった。RevenueCat::SubscriberClient でGET /v1/subscribers/{app_user_id}を叩き、RevenueCat::SubscriberSyncで entitlements/subscriptionsの現在状態(expires_date・grace_period・billing_issue・ unsubscribe等)をSubscriptionへ反映する。単発の状態確定処理のため、Webhook Handler と違い通知Jobは発火しない。 --- app/controllers/api/v1/pro/sync_controller.rb | 12 +- app/services/revenue_cat/subscriber_client.rb | 41 +++++++ app/services/revenue_cat/subscriber_sync.rb | 86 ++++++++++++++ spec/requests/api/v1/pro_spec.rb | 34 +++++- .../revenue_cat/subscriber_client_spec.rb | 27 +++++ .../revenue_cat/subscriber_sync_spec.rb | 105 ++++++++++++++++++ 6 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 app/services/revenue_cat/subscriber_client.rb create mode 100644 app/services/revenue_cat/subscriber_sync.rb create mode 100644 spec/services/revenue_cat/subscriber_client_spec.rb create mode 100644 spec/services/revenue_cat/subscriber_sync_spec.rb diff --git a/app/controllers/api/v1/pro/sync_controller.rb b/app/controllers/api/v1/pro/sync_controller.rb index 811ab81e..825e432c 100644 --- a/app/controllers/api/v1/pro/sync_controller.rb +++ b/app/controllers/api/v1/pro/sync_controller.rb @@ -2,21 +2,23 @@ module Api module V1 module Pro # POST /api/v1/pro/sync - # クライアントから「RevenueCat と Rails の状態を再同期してくれ」と要求する暫定エンドポイント。 - # 本実装は #318 で RevenueCat REST API を叩いて状態を取得・反映する形に差し替える予定。 - # 本 Issue では last_synced_at の刻印と現在状態の返却だけを行うスタブとする。 + # クライアントから「RevenueCat と Rails の状態を再同期してくれ」と要求されたときに、 + # RevenueCat REST API から現在のsubscriber状態を取得してSubscriptionへ反映する。 + # Webhookの取りこぼし・配信遅延時に、クライアント側の「同期更新」ボタンから叩く想定。 class SyncController < ApplicationController before_action :authenticate_api_v1_user! def create user = current_api_v1_user - subscription = user.subscription || user.create_subscription!(status: 'free') - subscription.update!(last_synced_at: Time.current) + subscription = RevenueCat::SubscriberSync.new(user).call render json: { subscription: ::V1::SubscriptionSerializer.new(subscription).as_json, entitlements: ::Entitlement::ALL_FEATURES.select { |key| user.has_entitlement?(key) } }, status: :ok + rescue RevenueCat::SubscriberClient::RequestFailedError => e + Sentry.capture_exception(e, tags: { source: 'pro_sync_controller' }) + render json: { error: 'revenuecat_api_error' }, status: :bad_gateway end end end diff --git a/app/services/revenue_cat/subscriber_client.rb b/app/services/revenue_cat/subscriber_client.rb new file mode 100644 index 00000000..bd33e311 --- /dev/null +++ b/app/services/revenue_cat/subscriber_client.rb @@ -0,0 +1,41 @@ +require 'net/http' +require 'uri' +require 'json' + +module RevenueCat + # RevenueCat REST API (GET /v1/subscribers/{app_user_id}) を叩く薄いクライアント。 + # サーバーサイド専用の secret key を使う(mobile側のSDK公開keyとは別物)。 + class SubscriberClient + BASE_URL = 'https://api.revenuecat.com/v1'.freeze + TIMEOUT_SECONDS = 5 + + class RequestFailedError < StandardError; end + + # @param app_user_id [String] RevenueCatのapp_user_id(このアプリではuser.id.to_sを使う) + # @return [Hash] `subscriber` 配下のHash + def self.fetch_subscriber(app_user_id) + new.fetch_subscriber(app_user_id) + end + + def fetch_subscriber(app_user_id) + uri = URI("#{BASE_URL}/subscribers/#{app_user_id}") + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Bearer #{secret_key}" + request['Content-Type'] = 'application/json' + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: TIMEOUT_SECONDS) do |http| + http.request(request) + end + + raise RequestFailedError, "RevenueCat API returned #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body)['subscriber'] || {} + end + + private + + def secret_key + ENV.fetch('REVENUECAT_SECRET_API_KEY') + end + end +end diff --git a/app/services/revenue_cat/subscriber_sync.rb b/app/services/revenue_cat/subscriber_sync.rb new file mode 100644 index 00000000..674ad3a1 --- /dev/null +++ b/app/services/revenue_cat/subscriber_sync.rb @@ -0,0 +1,86 @@ +module RevenueCat + # RevenueCat REST API から取得した現在のsubscriber状態でSubscriptionを上書きする。 + # Webhookの取りこぼし・遅延時に、クライアントの「同期更新」操作から明示的に叩く用途。 + # Webhook Handlerと違い単発の状態確定処理のため、通知Jobの発火は行わない + # (「今の状態を映すだけ」であり、ユーザーへ再度イベント通知すべきではないため)。 + class SubscriberSync + ENTITLEMENT_ID = 'pro'.freeze + + def initialize(user) + @user = user + end + + # @return [Subscription] 更新後のSubscription + def call + subscriber = SubscriberClient.fetch_subscriber(@user.id.to_s) + entitlement = subscriber.dig('entitlements', ENTITLEMENT_ID) + subscription = @user.subscription || @user.create_subscription!(status: 'free') + + if entitlement + apply_entitlement(subscription, subscriber, entitlement) + else + apply_no_entitlement(subscription) + end + + subscription + end + + private + + def apply_entitlement(subscription, subscriber, entitlement) + product_id = entitlement['product_identifier'] + subscription_detail = subscriber.dig('subscriptions', product_id) || {} + + subscription.update!(attributes_for(subscription, product_id, subscription_detail, entitlement)) + end + + def attributes_for(subscription, product_id, subscription_detail, entitlement) + expires_at = parse_time(entitlement['expires_date']) + effective_expires_at = grace_expires_at(entitlement, subscription_detail) || expires_at + is_trial = subscription_detail['period_type'] == 'TRIAL' + + { + status: status_for(subscription_detail, effective_expires_at, is_trial), + plan_type: PlanCatalog.plan_type_from(product_id), + platform: PlanCatalog.platform_from(subscription_detail['store'].to_s.upcase), + product_id:, + started_at: parse_time(entitlement['purchase_date']) || subscription.started_at, + expires_at:, + cancelled_at: parse_time(subscription_detail['unsubscribe_detected_at']) || subscription.cancelled_at, + refunded_at: parse_time(subscription_detail['refunded_at']) || subscription.refunded_at, + billing_issue_at: parse_time(subscription_detail['billing_issues_detected_at']) || subscription.billing_issue_at, + has_used_trial: subscription.has_used_trial || is_trial, + revenuecat_user_id: @user.id.to_s, + revenuecat_entitlement_id: ENTITLEMENT_ID, + last_synced_at: Time.current + } + end + + def grace_expires_at(entitlement, subscription_detail) + parse_time(entitlement['grace_period_expires_date'] || subscription_detail['grace_period_expires_date']) + end + + # entitlementが存在しない = 一度も加入していないか、期限切れでRevenueCat側から外れた状態。 + def apply_no_entitlement(subscription) + new_status = subscription.has_used_trial || subscription.product_id.present? ? 'expired' : 'free' + subscription.update!(status: new_status, last_synced_at: Time.current) + end + + def status_for(subscription_detail, effective_expires_at, is_trial) + return 'expired' if effective_expires_at.present? && effective_expires_at <= Time.current + return 'billing_issue' if subscription_detail['billing_issues_detected_at'].present? + return 'cancelled' if subscription_detail['unsubscribe_detected_at'].present? + return 'trial' if is_trial + + 'active' + end + + def parse_time(value) + return nil if value.blank? + + Time.zone.parse(value.to_s) + rescue ArgumentError + nil + end + end +end diff --git a/spec/requests/api/v1/pro_spec.rb b/spec/requests/api/v1/pro_spec.rb index c5f96566..33c0dd44 100644 --- a/spec/requests/api/v1/pro_spec.rb +++ b/spec/requests/api/v1/pro_spec.rb @@ -53,7 +53,9 @@ end context 'when authenticated' do - it 'updates last_synced_at and returns current state' do + it 'fetches the current RevenueCat subscriber state, updates last_synced_at, and returns it' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return({}) + expect do post '/api/v1/pro/sync', headers: auth_headers_for(user) end.to change { user.subscription.reload.last_synced_at }.from(nil) @@ -63,6 +65,36 @@ expect(json['subscription']['status']).to eq 'free' expect(json['entitlements']).to be_an(Array) end + + it 'reflects an active RevenueCat entitlement into the subscription' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return( + 'entitlements' => { + 'pro' => { + 'product_identifier' => 'buzzbase_pro_monthly', + 'purchase_date' => 1.day.ago.iso8601, + 'expires_date' => 29.days.from_now.iso8601 + } + }, + 'subscriptions' => { + 'buzzbase_pro_monthly' => { 'store' => 'app_store', 'period_type' => 'NORMAL' } + } + ) + + post '/api/v1/pro/sync', headers: auth_headers_for(user) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['subscription']['status']).to eq 'active' + end + + it 'returns bad_gateway when the RevenueCat API request fails' do + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber) + .and_raise(RevenueCat::SubscriberClient::RequestFailedError, 'RevenueCat API returned 500') + + post '/api/v1/pro/sync', headers: auth_headers_for(user) + + expect(response).to have_http_status(:bad_gateway) + expect(response.parsed_body['error']).to eq 'revenuecat_api_error' + end end end diff --git a/spec/services/revenue_cat/subscriber_client_spec.rb b/spec/services/revenue_cat/subscriber_client_spec.rb new file mode 100644 index 00000000..dec6d0a9 --- /dev/null +++ b/spec/services/revenue_cat/subscriber_client_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe RevenueCat::SubscriberClient do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('REVENUECAT_SECRET_API_KEY').and_return('sk_test_dummy') + end + + describe '#fetch_subscriber' do + it 'returns the subscriber Hash on success' do + response = instance_double(Net::HTTPOK, body: { subscriber: { 'entitlements' => {} } }.to_json) + allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(Net::HTTP).to receive(:start).and_return(response) + + result = described_class.new.fetch_subscriber('123') + expect(result).to eq({ 'entitlements' => {} }) + end + + it 'raises RequestFailedError on a non-success response' do + response = instance_double(Net::HTTPNotFound, code: '404') + allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(false) + allow(Net::HTTP).to receive(:start).and_return(response) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end + end +end diff --git a/spec/services/revenue_cat/subscriber_sync_spec.rb b/spec/services/revenue_cat/subscriber_sync_spec.rb new file mode 100644 index 00000000..2ba59019 --- /dev/null +++ b/spec/services/revenue_cat/subscriber_sync_spec.rb @@ -0,0 +1,105 @@ +require 'rails_helper' + +RSpec.describe RevenueCat::SubscriberSync do + let(:user) { create(:user) } + + def stub_subscriber(hash) + allow(RevenueCat::SubscriberClient).to receive(:fetch_subscriber).with(user.id.to_s).and_return(hash) + end + + # entitlement/subscription detail をマージした最小限のsubscriberレスポンスを組み立てる。 + def stub_entitlement(entitlement_overrides: {}, subscription_overrides: {}) + stub_subscriber( + 'entitlements' => { + 'pro' => { + 'product_identifier' => 'buzzbase_pro_monthly', + 'purchase_date' => 10.days.ago.iso8601, + 'expires_date' => 20.days.from_now.iso8601 + }.merge(entitlement_overrides) + }, + 'subscriptions' => { + 'buzzbase_pro_monthly' => { 'store' => 'app_store', 'period_type' => 'NORMAL' }.merge(subscription_overrides) + } + ) + end + + describe '#call' do + context 'entitlementが存在しない場合' do + it '一度も加入していなければ free のままにする' do + stub_subscriber({}) + subscription = described_class.new(user).call + expect(subscription.status).to eq('free') + end + + it '過去に加入していれば expired にする' do + user.subscription.update!(status: 'active', product_id: 'buzzbase_pro_monthly') + stub_subscriber({}) + subscription = described_class.new(user).call + expect(subscription.status).to eq('expired') + end + end + + context 'entitlementが存在する場合' do + it 'アクティブな加入をactiveとして反映する' do + stub_entitlement + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('active') + expect(subscription.plan_type).to eq('monthly') + expect(subscription.platform).to eq('ios') + expect(subscription.product_id).to eq('buzzbase_pro_monthly') + expect(subscription.last_synced_at).to be_present + end + end + + it 'period_type が TRIAL なら trial として反映する' do + stub_entitlement(subscription_overrides: { 'period_type' => 'TRIAL' }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('trial') + expect(subscription.has_used_trial).to be true + end + end + + it '期限切れ(グレース期間もなし)なら expired にする' do + stub_entitlement(entitlement_overrides: { 'expires_date' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + expect(subscription.status).to eq('expired') + end + + it '期限切れでもグレース期間内ならactiveのまま扱う' do + stub_entitlement( + entitlement_overrides: { + 'expires_date' => 1.day.ago.iso8601, + 'grace_period_expires_date' => 5.days.from_now.iso8601 + } + ) + subscription = described_class.new(user).call + expect(subscription.status).to eq('active') + end + + it 'billing_issues_detected_atがあればbilling_issueにする' do + stub_entitlement(subscription_overrides: { 'billing_issues_detected_at' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('billing_issue') + expect(subscription.billing_issue_at).to be_present + end + end + + it 'unsubscribe_detected_atがあればcancelledにする' do + stub_entitlement(subscription_overrides: { 'store' => 'stripe', 'unsubscribe_detected_at' => 1.day.ago.iso8601 }) + subscription = described_class.new(user).call + + aggregate_failures do + expect(subscription.status).to eq('cancelled') + expect(subscription.platform).to eq('web') + expect(subscription.cancelled_at).to be_present + end + end + end + end +end From 12963733fab87dead8e0572b555735ad99ed8428 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 13:39:18 +0900 Subject: [PATCH 05/19] =?UTF-8?q?Fix:=20is=5Fadmin=E3=81=8C=E4=BB=96?= =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E8=A1=A8=E7=A4=BA=E3=83=BB?= =?UTF-8?q?=E6=A4=9C=E7=B4=A2API=E3=81=A7=E6=BC=8F=E6=B4=A9=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=9F=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 強制Proモード実装で追加したusers.is_adminが、v1のUsersController#show_user_id_data (他ユーザーのプロフィール閲覧)と#search(ユーザー検索)でas_json経由でそのまま レスポンスに含まれてしまっていた。内部向けの開発用フラグを他ユーザーからも 参照できてしまう状態だったため、except: :is_adminで除外する。 #show(自分自身の情報取得)も同様に除外し、旧クライアント向けv1 API形状の golden snapshotを変更しない。 --- app/controllers/api/v1/users_controller.rb | 8 +++++--- spec/requests/api/v1/users_private_account_spec.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 3653399c..76eb1d49 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -36,7 +36,7 @@ def show_user_id_data if user.profile_visible_to?(current_api_v1_user) render json: { - user: user.as_json, + user: user.as_json(except: :is_admin), isFollowing: is_following, follow_status:, following_count: user.following_count, @@ -67,7 +67,9 @@ def show_current_user_details end def show - render json: current_api_v1_user + # is_admin は development の強制Proモード判定にのみ使う内部フラグのため、 + # 旧クライアントも使うv1のレスポンス形(golden snapshot)には含めない。 + render json: current_api_v1_user, except: :is_admin end def update @@ -121,7 +123,7 @@ def search users = User.where('name LIKE ? OR user_id LIKE ?', "%#{query}%", "%#{query}%") .order(created_at: :desc) render json: users.map { |user| - user.as_json.merge(is_private: user.is_private?) + user.as_json(except: :is_admin).merge(is_private: user.is_private?) } end diff --git a/spec/requests/api/v1/users_private_account_spec.rb b/spec/requests/api/v1/users_private_account_spec.rb index 8f9f23fc..80aaa1ff 100644 --- a/spec/requests/api/v1/users_private_account_spec.rb +++ b/spec/requests/api/v1/users_private_account_spec.rb @@ -21,6 +21,12 @@ expect(json['following_count']).not_to be_nil expect(json['followers_count']).not_to be_nil end + + it 'does not leak the internal is_admin flag' do + get '/api/v1/users/show_user_id_data', params: { user_id: public_user.user_id } + + expect(response.parsed_body['user']).not_to have_key('is_admin') + end end context 'when viewing a private user as a follower' do @@ -201,5 +207,13 @@ expect(private_result['is_private']).to be true expect(public_result['is_private']).to be false end + + it 'does not leak the internal is_admin flag' do + public_user + + get '/api/v1/users/search', params: { query: 'user' } + + expect(response.parsed_body).to all(satisfy { |u| !u.key?('is_admin') }) + end end end From 25d738963ec9974c6ecdb64699376736d9fac943 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 13:46:05 +0900 Subject: [PATCH 06/19] =?UTF-8?q?Add:=20=E7=B4=A0=E6=8C=AF=E3=82=8A?= =?UTF-8?q?=E3=82=AB=E3=82=A6=E3=83=B3=E3=82=BF=E3=83=BC=E3=81=AE=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=B0=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89?= =?UTF-8?q?=E7=B6=99=E7=B6=9A=E5=AE=9F=E8=A1=8Centitlement=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-16(バックグラウンド継続実行)がPro差別化要素としてPRDに明記されているが 対応するentitlementキー自体が存在しなかった。shadow_swing_backgroundを追加する。 実装はmobile側(counter.tsx)。 --- app/models/concerns/entitlement.rb | 1 + spec/models/concerns/entitlement_spec.rb | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/concerns/entitlement.rb b/app/models/concerns/entitlement.rb index bd9ce48a..ed6b0690 100644 --- a/app/models/concerns/entitlement.rb +++ b/app/models/concerns/entitlement.rb @@ -43,6 +43,7 @@ module Entitlement 'manual_metric_goals', # 自由指標(手動更新)の目標設定(無料は利用不可) 'shadow_swing_custom_interval', # 素振りカウンターのインターバル自由設定(無料は5〜10秒のみ) 'shadow_swing_vibration', # 素振りカウンターのバイブレーション設定(無料は利用不可) + 'shadow_swing_background', # 素振りカウンターのバックグラウンド継続実行(無料はバックグラウンド遷移で一時停止) 'unlimited_groups', # グループ作成・参加を無制限に(無料は所属1件まで) 'hit_direction_average', # 方向別の打率(打球方向ごとのヒートマップ) 'count_situation_average', # カウント別の打率(初球・有利・追い込み等) diff --git a/spec/models/concerns/entitlement_spec.rb b/spec/models/concerns/entitlement_spec.rb index 9591cc37..7d5e4dd2 100644 --- a/spec/models/concerns/entitlement_spec.rb +++ b/spec/models/concerns/entitlement_spec.rb @@ -4,11 +4,11 @@ let(:user) { create(:user) } describe 'feature key constants' do - it 'defines exactly 10 free features and 29 pro features' do + it 'defines exactly 10 free features and 30 pro features' do # %w[] とインラインコメントの混在で feature key が壊れる回帰を防ぐ expect(described_class::FREE_FEATURES.size).to eq 10 - expect(described_class::PRO_FEATURES.size).to eq 29 - expect(described_class::ALL_FEATURES.size).to eq 39 + expect(described_class::PRO_FEATURES.size).to eq 30 + expect(described_class::ALL_FEATURES.size).to eq 40 end it 'contains only valid feature key strings (no stray symbols)' do From a1559f44006b4bb43f1b5addc0cbdc7efec9f1df Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 13:57:52 +0900 Subject: [PATCH 07/19] =?UTF-8?q?Add:=20=E3=82=AB=E3=83=AC=E3=83=B3?= =?UTF-8?q?=E3=83=80=E3=83=BC=E4=BF=AF=E7=9E=B0=E3=81=AE=E9=96=B2=E8=A6=A7?= =?UTF-8?q?=E7=AF=84=E5=9B=B2=E3=81=ABPro/=E7=84=A1=E6=96=99=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E3=81=97=E5=88=86=E3=81=91=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/v2/plans/calendar が entitlement を一切見ておらず、無料ユーザーも 全期間を閲覧できてしまっていた。schedule_calendar_full_history entitlementを 追加し、無料は当日の前後15日(直近月中心)にfrom/toをクランプする。 --- app/controllers/api/v2/plans_controller.rb | 9 +++++++ app/models/concerns/entitlement.rb | 1 + spec/models/concerns/entitlement_spec.rb | 6 ++--- spec/requests/api/v2/plans_spec.rb | 31 ++++++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb index 9586a141..d0f0d4b8 100644 --- a/app/controllers/api/v2/plans_controller.rb +++ b/app/controllers/api/v2/plans_controller.rb @@ -6,6 +6,9 @@ module V2 class PlansController < Api::V2::ApplicationController before_action :authenticate_api_v1_user! + # 無料ユーザーのカレンダー俯瞰は「直近月中心」に閲覧範囲を絞る(前後15日)。 + FREE_CALENDAR_WINDOW_DAYS = 15 + def by_date date = parse_date(params[:date]) return render json: { error: 'date が不正です' }, status: :unprocessable_entity if date.nil? @@ -21,6 +24,12 @@ def calendar to = parse_date(params[:to]) return render json: { error: 'from / to が不正です' }, status: :unprocessable_entity if from.nil? || to.nil? || to < from + unless current_api_v1_user.has_entitlement?('schedule_calendar_full_history') + today = Time.find_zone('Asia/Tokyo').today + from = [from, today - FREE_CALENDAR_WINDOW_DAYS].max + to = [to, today + FREE_CALENDAR_WINDOW_DAYS].min + end + entries = (from..to).flat_map do |date| plans_on(date).map do |schedule| { date: date.iso8601, event_type: schedule.event_type, title: schedule.display_title, schedule_id: schedule.id } diff --git a/app/models/concerns/entitlement.rb b/app/models/concerns/entitlement.rb index ed6b0690..e96320d2 100644 --- a/app/models/concerns/entitlement.rb +++ b/app/models/concerns/entitlement.rb @@ -44,6 +44,7 @@ module Entitlement 'shadow_swing_custom_interval', # 素振りカウンターのインターバル自由設定(無料は5〜10秒のみ) 'shadow_swing_vibration', # 素振りカウンターのバイブレーション設定(無料は利用不可) 'shadow_swing_background', # 素振りカウンターのバックグラウンド継続実行(無料はバックグラウンド遷移で一時停止) + 'schedule_calendar_full_history', # カレンダー俯瞰の全期間閲覧(無料は直近月中心) 'unlimited_groups', # グループ作成・参加を無制限に(無料は所属1件まで) 'hit_direction_average', # 方向別の打率(打球方向ごとのヒートマップ) 'count_situation_average', # カウント別の打率(初球・有利・追い込み等) diff --git a/spec/models/concerns/entitlement_spec.rb b/spec/models/concerns/entitlement_spec.rb index 7d5e4dd2..84f10c2e 100644 --- a/spec/models/concerns/entitlement_spec.rb +++ b/spec/models/concerns/entitlement_spec.rb @@ -4,11 +4,11 @@ let(:user) { create(:user) } describe 'feature key constants' do - it 'defines exactly 10 free features and 30 pro features' do + it 'defines exactly 10 free features and 31 pro features' do # %w[] とインラインコメントの混在で feature key が壊れる回帰を防ぐ expect(described_class::FREE_FEATURES.size).to eq 10 - expect(described_class::PRO_FEATURES.size).to eq 30 - expect(described_class::ALL_FEATURES.size).to eq 40 + expect(described_class::PRO_FEATURES.size).to eq 31 + expect(described_class::ALL_FEATURES.size).to eq 41 end it 'contains only valid feature key strings (no stray symbols)' do diff --git a/spec/requests/api/v2/plans_spec.rb b/spec/requests/api/v2/plans_spec.rb index e8346966..5bce3c7f 100644 --- a/spec/requests/api/v2/plans_spec.rb +++ b/spec/requests/api/v2/plans_spec.rb @@ -81,6 +81,8 @@ end it '期間内の予定を日別エントリで返す' do + # 無料の閲覧範囲クランプ(直近月中心)の影響を受けないようにする。日付自体はテスト対象外。 + make_pro(user) create(:schedule, user:, title: '朝練', days_of_week: '1', scheduled_time: '06:00') create(:schedule, user:, title: '試合', days_of_week: nil, planned_on: '2026-07-08', event_type: 'game') @@ -92,5 +94,34 @@ game_entry = entries.find { |entry| entry['event_type'] == 'game' } expect(game_entry['title']).to eq('試合') end + + context '無料ユーザーの閲覧範囲(直近月中心)' do + it '前後15日を超える未来の予定はクランプされて含まれない' do + today = Time.zone.today + far_future = today + 40 + create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) + + get '/api/v2/plans/calendar', + params: { from: today.iso8601, to: (far_future + 1).iso8601 }, + headers: auth_headers_for(user) + + dates = response.parsed_body['entries'].pluck('date') + expect(dates).not_to include(far_future.iso8601) + end + + it 'schedule_calendar_full_historyを持つProユーザーはクランプされない' do + make_pro(user) + today = Time.zone.today + far_future = today + 40 + create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) + + get '/api/v2/plans/calendar', + params: { from: today.iso8601, to: (far_future + 1).iso8601 }, + headers: auth_headers_for(user) + + dates = response.parsed_body['entries'].pluck('date') + expect(dates).to include(far_future.iso8601) + end + end end end From 9bac52ed0f6b41ebae04468ffb6fe17cbfc4d241 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 14:07:32 +0900 Subject: [PATCH 08/19] =?UTF-8?q?Add:=20=E9=98=B2=E5=BE=A1=E7=8E=87?= =?UTF-8?q?=E6=8E=A8=E7=A7=BB(EraTrendChart)=E3=81=AB=E3=82=B7=E3=83=BC?= =?UTF-8?q?=E3=82=BA=E3=83=B3=E7=B2=92=E5=BA=A6=E3=83=BB=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E3=83=99=E3=82=B9=E3=83=88=E5=BC=B7=E8=AA=BF=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD-06で投手側も打者側と同様のシーズン跨ぎ比較・自己ベスト強調が求められていたが 月別集計のみでgranularity自体が存在しなかった。EraTrendServiceにseason粒度を追加し season_transition_graph entitlementで打者側と同じくPro限定にする。season粒度選択時の season_idクランプもbatting_trendと共通化(season_aware_params)した。 レスポンス形状を{trend: [{month, era}]}から{granularity, points: [{key, label, era}]}へ 統一し、batting_trendと同じ形にした。 --- app/controllers/api/v2/stats_controller.rb | 15 ++-- app/services/stats/era_trend_service.rb | 83 ++++++++++++++++------ spec/requests/api/v2/stats_spec.rb | 32 ++++++++- 3 files changed, 101 insertions(+), 29 deletions(-) diff --git a/app/controllers/api/v2/stats_controller.rb b/app/controllers/api/v2/stats_controller.rb index 3535e43f..73ce8e81 100644 --- a/app/controllers/api/v2/stats_controller.rb +++ b/app/controllers/api/v2/stats_controller.rb @@ -31,7 +31,14 @@ def pitching end def era_trend - render json: { trend: Stats::EraTrendService.new(**aggregator_params.except(:match_type)).call } + # シーズン粒度(シーズン跨ぎ推移)は Pro 限定。既存の月別は無料据え置き。 + if params[:granularity].to_s == 'season' && !current_api_v1_user.has_entitlement?('season_transition_graph') + return render json: { error: 'シーズン推移は Pro プラン限定です' }, status: :forbidden + end + + render json: Stats::EraTrendService.new( + **season_aware_params.except(:match_type), granularity: params[:granularity] + ).call end def game_summary @@ -69,7 +76,7 @@ def batting_trend end render json: Stats::BattingTrendAggregator.new( - **batting_trend_params, granularity: params[:granularity] + **season_aware_params, granularity: params[:granularity] ).call end @@ -111,8 +118,8 @@ def aggregator_params # granularity=season はシーズン跨ぎで全シーズンを比較する機能のため、 # season_id による単一シーズン絞り込みと併用すると1シーズンに縮退してしまう。 - # season 粒度選択時は season_id を無視する。 - def batting_trend_params + # season 粒度選択時は season_id を無視する(batting_trend / era_trend 共通)。 + def season_aware_params return aggregator_params unless params[:granularity].to_s == 'season' aggregator_params.merge(season_id: nil) diff --git a/app/services/stats/era_trend_service.rb b/app/services/stats/era_trend_service.rb index fd5b83df..5b9f64c1 100644 --- a/app/services/stats/era_trend_service.rb +++ b/app/services/stats/era_trend_service.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true module Stats + # 防御率推移グラフ用 Service。 + # 月単位 (granularity=month、デフォルト) もしくはシーズン単位 (granularity=season) で + # 時系列集計し、各時点のERAを返す。ERA は + # `SUM(earned_run × match_results.inning_format) / SUM(innings_pitched)` で算出し、 + # 7回制/9回制が混在しても各試合のイニング制を加重した値で計算する。 class EraTrendService - def initialize(user_id:, year: nil, season_id: nil, tournament_id: nil, start_month: nil, end_month: nil) + SUPPORTED_GRANULARITIES = %w[month season].freeze + + def initialize(user_id:, granularity: 'month', + year: nil, season_id: nil, tournament_id: nil, start_month: nil, end_month: nil) @user_id = user_id + @granularity = SUPPORTED_GRANULARITIES.include?(granularity.to_s) ? granularity.to_s : 'month' @year = year @season_id = season_id @tournament_id = tournament_id @@ -11,34 +20,64 @@ def initialize(user_id:, year: nil, season_id: nil, tournament_id: nil, start_mo @end_month = end_month end - # 月別の防御率推移を返す。 - # ERA は `SUM(earned_run × match_results.inning_format) / SUM(innings_pitched)` で算出し、 - # 7回制/9回制が混在しても各試合のイニング制を加重した値で計算する。 - # @return [ArrayNumeric}>] [{ month: Integer, era: Float }, ...] + # @return [Hash] granularity と points 配列。points は key / label / era を持つ。 def call + points = @granularity == 'season' ? aggregate_by_season : aggregate_by_month + { granularity: @granularity, points: } + end + + private + + def aggregate_by_month scope = base_scope return [] if scope.none? - # 月ごとに集計(earned_run には inning_format を係数として掛けて加重する) - monthly = scope - .select(Arel.sql( - "#{Stats::JstDateSql::MONTH_JST_INT_SQL} AS month, " \ - 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ - 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' - )) - .group(Arel.sql(Stats::JstDateSql::MONTH_JST_INT_SQL)) - .order(Arel.sql('month')) - - monthly.filter_map do |r| - ip = r.total_ip.to_f - next if ip <= 0 - - era = (r.total_weighted_er.to_f / ip).round(2) - { month: r.month, era: } + rows = scope + .select(Arel.sql( + "#{Stats::JstDateSql::MONTH_JST_INT_SQL} AS month, " \ + 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ + 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' + )) + .group(Arel.sql(Stats::JstDateSql::MONTH_JST_INT_SQL)) + .order(Arel.sql('month')) + + rows.filter_map do |r| + era = era_for(total_ip: r.total_ip, total_weighted_er: r.total_weighted_er) + next if era.nil? + + { key: format('month-%02d', month: r.month.to_i), label: "#{r.month}月", era: } end end - private + # シーズンごとのERAを返す(シーズン跨ぎ比較)。season_id が未割り当ての試合は集計対象外。 + def aggregate_by_season + scope = base_scope + return [] if scope.none? + + rows = scope + .joins('INNER JOIN seasons ON seasons.id = game_results.season_id') + .select(Arel.sql( + 'seasons.id AS season_id, seasons.name AS season_name, seasons.created_at AS season_created_at, ' \ + 'SUM(pitching_results.innings_pitched) AS total_ip, ' \ + 'SUM(pitching_results.earned_run * match_results.inning_format) AS total_weighted_er' + )) + .group('seasons.id, seasons.name, seasons.created_at') + .order(Arel.sql('seasons.created_at ASC')) + + rows.filter_map do |r| + era = era_for(total_ip: r.total_ip, total_weighted_er: r.total_weighted_er) + next if era.nil? + + { key: "season-#{r.season_id}", label: r.season_name, era: } + end + end + + def era_for(total_ip:, total_weighted_er:) + ip = total_ip.to_f + return nil if ip <= 0 + + (total_weighted_er.to_f / ip).round(2) + end def base_scope scope = PitchingResult.joins(game_result: :match_result) diff --git a/spec/requests/api/v2/stats_spec.rb b/spec/requests/api/v2/stats_spec.rb index d972b9da..23999e03 100644 --- a/spec/requests/api/v2/stats_spec.rb +++ b/spec/requests/api/v2/stats_spec.rb @@ -93,13 +93,39 @@ expect(response).to have_http_status(:unauthorized) end - it 'returns 200 with trend array' do + it 'returns 200 with granularity (default month) + points array' do get('/api/v2/stats/era_trend', headers:) expect(response).to have_http_status(:ok) json = response.parsed_body - expect(json['trend']).to be_an(Array) - expect(json['trend'].first).to include('month', 'era') if json['trend'].any? + expect(json['granularity']).to eq('month') + expect(json['points']).to be_an(Array) + expect(json['points'].first).to include('key', 'label', 'era') if json['points'].any? + end + + it 'returns 403 for granularity=season when the user is free' do + get('/api/v2/stats/era_trend', params: { granularity: 'season' }, headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'Pro ユーザー' do + before { make_pro(user) } + + it 'returns season-granularity points for a Pro user' do + season1 = create(:season, user:, name: '2026春') + season2 = create(:season, user:, name: '2026夏') + game1 = create(:game_result, user:, season: season1) + game2 = create(:game_result, user:, season: season2) + create(:pitching_result, game_result: game1, user:, innings_pitched: 6, earned_run: 2) + create(:pitching_result, game_result: game2, user:, innings_pitched: 6, earned_run: 3) + + get('/api/v2/stats/era_trend', params: { granularity: 'season' }, headers:) + + expect(response).to have_http_status(:ok) + json = response.parsed_body + expect(json['granularity']).to eq('season') + expect(json['points'].pluck('label')).to contain_exactly('2026春', '2026夏') + end end end From 22de1466ebde173402e8e8a282e934afb52bea2e Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 14:21:47 +0900 Subject: [PATCH 09/19] =?UTF-8?q?Add:=20=E9=81=94=E6=88=90=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=B8(goal=5Fbadges)=E3=82=92=E9=96=B2=E8=A6=A7?= =?UTF-8?q?=E3=81=99=E3=82=8BAPI=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FinalizeGoalsJobがgoal_badgesを作成する一方、閲覧できるAPI・UIが存在せず DoDの「バッジ獲得が動作する」が実質未達だった。GET /api/v2/goal_badgesで 自分のバッジを新しい順に返す(閲覧のみ、付与は引き続きFinalizeGoalsJobが担当)。 --- .../api/v2/goal_badges_controller.rb | 13 ++++++ app/serializers/v2/goal_badge_serializer.rb | 9 ++++ config/routes.rb | 1 + spec/factories/goal_badges.rb | 9 ++++ spec/requests/api/v2/goal_badges_spec.rb | 41 +++++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 app/controllers/api/v2/goal_badges_controller.rb create mode 100644 app/serializers/v2/goal_badge_serializer.rb create mode 100644 spec/factories/goal_badges.rb create mode 100644 spec/requests/api/v2/goal_badges_spec.rb diff --git a/app/controllers/api/v2/goal_badges_controller.rb b/app/controllers/api/v2/goal_badges_controller.rb new file mode 100644 index 00000000..e0b1549b --- /dev/null +++ b/app/controllers/api/v2/goal_badges_controller.rb @@ -0,0 +1,13 @@ +module Api + module V2 + # 目標達成バッジの閲覧。付与はFinalizeGoalsJobが行い、ここは一覧取得のみ。 + class GoalBadgesController < Api::V2::ApplicationController + before_action :authenticate_api_v1_user! + + def index + badges = current_api_v1_user.goal_badges.includes(:goal).order(awarded_at: :desc) + render json: badges, each_serializer: ::V2::GoalBadgeSerializer, status: :ok + end + end + end +end diff --git a/app/serializers/v2/goal_badge_serializer.rb b/app/serializers/v2/goal_badge_serializer.rb new file mode 100644 index 00000000..5af3e751 --- /dev/null +++ b/app/serializers/v2/goal_badge_serializer.rb @@ -0,0 +1,9 @@ +module V2 + class GoalBadgeSerializer < ActiveModel::Serializer + attributes :id, :badge_type, :badge_name, :awarded_at, :goal_id, :goal_title + + def goal_title + object.goal.title + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 36b28a38..e2c13f42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -294,6 +294,7 @@ collection { get :history } resource :achievement, only: %i[create destroy], controller: 'goals/achievements' end + resources :goal_badges, only: %i[index] resources :baseball_notes, only: %i[index show create update destroy] resources :improvement_themes, only: %i[index create update destroy] resources :reflection_templates, only: %i[index create update destroy] diff --git a/spec/factories/goal_badges.rb b/spec/factories/goal_badges.rb new file mode 100644 index 00000000..eaeeee1b --- /dev/null +++ b/spec/factories/goal_badges.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :goal_badge do + user + goal + badge_type { 'monthly_achieved' } + badge_name { '月間目標達成' } + awarded_at { Time.current } + end +end diff --git a/spec/requests/api/v2/goal_badges_spec.rb b/spec/requests/api/v2/goal_badges_spec.rb new file mode 100644 index 00000000..73e5663f --- /dev/null +++ b/spec/requests/api/v2/goal_badges_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Api::V2::GoalBadges', type: :request do + let(:user) { create(:user) } + let(:headers) { auth_headers_for(user) } + + describe 'GET /api/v2/goal_badges' do + it 'returns 401 when not authenticated' do + get '/api/v2/goal_badges' + expect(response).to have_http_status(:unauthorized) + end + + it '自分のバッジを新しい順で返す' do + goal = create(:goal, user:) + older = create(:goal_badge, user:, goal:, badge_name: '月間目標達成', awarded_at: 2.days.ago) + newer = create(:goal_badge, user:, goal:, badge_name: 'シーズン目標達成', awarded_at: 1.day.ago) + + get('/api/v2/goal_badges', headers:) + expect(response).to have_http_status(:ok) + badge_ids = response.parsed_body.pluck('id') + expect(badge_ids).to eq([newer.id, older.id]) + end + + it '他ユーザーのバッジは含まれない' do + other_user = create(:user) + other_goal = create(:goal, user: other_user) + create(:goal_badge, user: other_user, goal: other_goal) + + get('/api/v2/goal_badges', headers:) + expect(response.parsed_body).to eq([]) + end + + it 'goal_titleにバッジの元になった目標のタイトルを含む' do + goal = create(:goal, user:, title: '今月20日練習') + create(:goal_badge, user:, goal:) + + get('/api/v2/goal_badges', headers:) + expect(response.parsed_body.first['goal_title']).to eq('今月20日練習') + end + end +end From d369278111bf79c9ca6d968b3d62f3a1aad32d77 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 15:28:07 +0900 Subject: [PATCH 10/19] =?UTF-8?q?Fix:=20SubscriberSync=E3=81=8C=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B9=E6=9C=9F=E9=96=93=E4=B8=AD=E3=81=AB?= =?UTF-8?q?expires=5Fat=E3=81=B8=E7=94=9F=E3=81=AE=E6=9C=9F=E9=99=90?= =?UTF-8?q?=E3=82=92=E4=BF=9D=E5=AD=98=E3=81=97pro=5Factive=3F=E3=82=92?= =?UTF-8?q?=E5=A3=8A=E3=81=99=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status判定はgrace_period_expires_dateを考慮したeffective_expires_atを使う一方、 expires_atカラムには生のentitlement['expires_date']を保存していたため、 グレース期間中に同期するとstatusは'active'/'billing_issue'のままexpires_atだけ 過去日付になり、Subscription#pro_active?がfalseを返してPro機能が失われていた。 Webhook Handler群がグレース中にexpires_atを変更しない設計と揃え、 effective_expires_atを保存するようにする。 --- app/services/revenue_cat/subscriber_sync.rb | 4 +++- spec/services/revenue_cat/subscriber_sync_spec.rb | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/services/revenue_cat/subscriber_sync.rb b/app/services/revenue_cat/subscriber_sync.rb index 674ad3a1..b4f76e76 100644 --- a/app/services/revenue_cat/subscriber_sync.rb +++ b/app/services/revenue_cat/subscriber_sync.rb @@ -45,7 +45,9 @@ def attributes_for(subscription, product_id, subscription_detail, entitlement) platform: PlanCatalog.platform_from(subscription_detail['store'].to_s.upcase), product_id:, started_at: parse_time(entitlement['purchase_date']) || subscription.started_at, - expires_at:, + # Subscription#pro_active? / #in_grace_period? はこのカラムで期限内かを判定するため、 + # グレース期間中はグレース期限を保存する(webhook Handlerと同様、グレース中はPro機能を維持する)。 + expires_at: effective_expires_at, cancelled_at: parse_time(subscription_detail['unsubscribe_detected_at']) || subscription.cancelled_at, refunded_at: parse_time(subscription_detail['refunded_at']) || subscription.refunded_at, billing_issue_at: parse_time(subscription_detail['billing_issues_detected_at']) || subscription.billing_issue_at, diff --git a/spec/services/revenue_cat/subscriber_sync_spec.rb b/spec/services/revenue_cat/subscriber_sync_spec.rb index 2ba59019..9c9f7db4 100644 --- a/spec/services/revenue_cat/subscriber_sync_spec.rb +++ b/spec/services/revenue_cat/subscriber_sync_spec.rb @@ -69,7 +69,7 @@ def stub_entitlement(entitlement_overrides: {}, subscription_overrides: {}) expect(subscription.status).to eq('expired') end - it '期限切れでもグレース期間内ならactiveのまま扱う' do + it '期限切れでもグレース期間内ならactiveのまま扱い、pro_active?も維持する' do stub_entitlement( entitlement_overrides: { 'expires_date' => 1.day.ago.iso8601, @@ -77,7 +77,14 @@ def stub_entitlement(entitlement_overrides: {}, subscription_overrides: {}) } ) subscription = described_class.new(user).call - expect(subscription.status).to eq('active') + + aggregate_failures do + expect(subscription.status).to eq('active') + # expires_atにグレース期限ではなく本来のexpires_dateだけを保存すると、 + # pro_active?/in_grace_period?が期限切れ判定してしまい、statusと矛盾する。 + expect(subscription.pro_active?).to be true + expect(subscription.expires_at).to be > Time.current + end end it 'billing_issues_detected_atがあればbilling_issueにする' do From 46085f7572d0475ba2921bb2fc40678fee68d080 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 15:28:14 +0900 Subject: [PATCH 11/19] =?UTF-8?q?Fix:=20SubscriberClient=E3=81=AE=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=A0=E3=82=A2=E3=82=A6=E3=83=88=E3=83=BB=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=8C502=E3=83=8F?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E3=82=92=E8=BF=82?= =?UTF-8?q?=E5=9B=9E=E3=81=99=E3=82=8B=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net::HTTP.startのタイムアウト・接続断・TLSエラーがRequestFailedErrorへ 変換されておらず、SyncControllerのrescueをすり抜けて素の500になっていた。 想定される主要な失敗モードをRequestFailedErrorへ変換し、意図通りbad_gatewayに 倒れるようにする。open_timeoutも明示的に設定した。 --- app/services/revenue_cat/subscriber_client.rb | 12 +++++++++++- spec/services/revenue_cat/subscriber_client_spec.rb | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/services/revenue_cat/subscriber_client.rb b/app/services/revenue_cat/subscriber_client.rb index bd33e311..5df7b2f7 100644 --- a/app/services/revenue_cat/subscriber_client.rb +++ b/app/services/revenue_cat/subscriber_client.rb @@ -11,6 +11,13 @@ class SubscriberClient class RequestFailedError < StandardError; end + # タイムアウト・接続断・TLSエラー等はRequestFailedErrorへ変換し、 + # 呼び出し側(SyncController)が一律 bad_gateway として扱えるようにする。 + NETWORK_ERRORS = [ + Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, OpenSSL::SSL::SSLError, + Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError + ].freeze + # @param app_user_id [String] RevenueCatのapp_user_id(このアプリではuser.id.to_sを使う) # @return [Hash] `subscriber` 配下のHash def self.fetch_subscriber(app_user_id) @@ -23,13 +30,16 @@ def fetch_subscriber(app_user_id) request['Authorization'] = "Bearer #{secret_key}" request['Content-Type'] = 'application/json' - response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: TIMEOUT_SECONDS) do |http| + response = Net::HTTP.start(uri.host, uri.port, + use_ssl: true, open_timeout: TIMEOUT_SECONDS, read_timeout: TIMEOUT_SECONDS) do |http| http.request(request) end raise RequestFailedError, "RevenueCat API returned #{response.code}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body)['subscriber'] || {} + rescue *NETWORK_ERRORS => e + raise RequestFailedError, "RevenueCat API request failed: #{e.message}" end private diff --git a/spec/services/revenue_cat/subscriber_client_spec.rb b/spec/services/revenue_cat/subscriber_client_spec.rb index dec6d0a9..eb504519 100644 --- a/spec/services/revenue_cat/subscriber_client_spec.rb +++ b/spec/services/revenue_cat/subscriber_client_spec.rb @@ -23,5 +23,17 @@ expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) end + + it 'converts a read timeout into RequestFailedError instead of leaking a raw Net::ReadTimeout' do + allow(Net::HTTP).to receive(:start).and_raise(Net::ReadTimeout) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end + + it 'converts a connection refusal into RequestFailedError' do + allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED) + + expect { described_class.new.fetch_subscriber('123') }.to raise_error(described_class::RequestFailedError) + end end end From 476af36cd73786d23f7eb831f100ffcc6eeb99c4 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 21:51:00 +0900 Subject: [PATCH 12/19] =?UTF-8?q?Revert=20"Fix:=20is=5Fadmin=E3=81=8C?= =?UTF-8?q?=E4=BB=96=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=83=BB=E6=A4=9C=E7=B4=A2API=E3=81=A7=E6=BC=8F=E6=B4=A9?= =?UTF-8?q?=E3=81=97=E3=81=A6=E3=81=84=E3=81=9F=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 12963733fab87dead8e0572b555735ad99ed8428. --- app/controllers/api/v1/users_controller.rb | 8 +++----- spec/requests/api/v1/users_private_account_spec.rb | 14 -------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 76eb1d49..3653399c 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -36,7 +36,7 @@ def show_user_id_data if user.profile_visible_to?(current_api_v1_user) render json: { - user: user.as_json(except: :is_admin), + user: user.as_json, isFollowing: is_following, follow_status:, following_count: user.following_count, @@ -67,9 +67,7 @@ def show_current_user_details end def show - # is_admin は development の強制Proモード判定にのみ使う内部フラグのため、 - # 旧クライアントも使うv1のレスポンス形(golden snapshot)には含めない。 - render json: current_api_v1_user, except: :is_admin + render json: current_api_v1_user end def update @@ -123,7 +121,7 @@ def search users = User.where('name LIKE ? OR user_id LIKE ?', "%#{query}%", "%#{query}%") .order(created_at: :desc) render json: users.map { |user| - user.as_json(except: :is_admin).merge(is_private: user.is_private?) + user.as_json.merge(is_private: user.is_private?) } end diff --git a/spec/requests/api/v1/users_private_account_spec.rb b/spec/requests/api/v1/users_private_account_spec.rb index 80aaa1ff..8f9f23fc 100644 --- a/spec/requests/api/v1/users_private_account_spec.rb +++ b/spec/requests/api/v1/users_private_account_spec.rb @@ -21,12 +21,6 @@ expect(json['following_count']).not_to be_nil expect(json['followers_count']).not_to be_nil end - - it 'does not leak the internal is_admin flag' do - get '/api/v1/users/show_user_id_data', params: { user_id: public_user.user_id } - - expect(response.parsed_body['user']).not_to have_key('is_admin') - end end context 'when viewing a private user as a follower' do @@ -207,13 +201,5 @@ expect(private_result['is_private']).to be true expect(public_result['is_private']).to be false end - - it 'does not leak the internal is_admin flag' do - public_user - - get '/api/v1/users/search', params: { query: 'user' } - - expect(response.parsed_body).to all(satisfy { |u| !u.key?('is_admin') }) - end end end From 7036a1bfe33c5353662fa3043ba1cccf83e6f724 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 21:51:00 +0900 Subject: [PATCH 13/19] =?UTF-8?q?Revert=20"Add:=20development=E7=92=B0?= =?UTF-8?q?=E5=A2=83=E3=81=AEadmin=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E5=BC=B7=E5=88=B6Pro=E3=83=A2=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f0058724764d437b77cb876b3f0c051a22651f44. --- app/models/subscription.rb | 2 -- .../20260728010001_add_is_admin_to_users.rb | 5 ---- db/schema.rb | 3 +-- lib/tasks/pro.rake | 10 -------- spec/lib/tasks/pro_rake_spec.rb | 25 ------------------- spec/models/subscription_spec.rb | 25 ------------------- 6 files changed, 1 insertion(+), 69 deletions(-) delete mode 100644 db/migrate/20260728010001_add_is_admin_to_users.rb diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 6ef80d7e..e92e6db4 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -30,10 +30,8 @@ class Subscription < ApplicationRecord # Pro 機能が利用可能か。 # 期限内かつ status が trial / active / cancelled / billing_issue のとき true。 - # development 環境の admin ユーザーは課金なしで常に Pro 扱いにする(強制 Pro モード)。 # @return [Boolean] def pro_active? - return true if Rails.env.development? && user.is_admin? return false unless PRO_ACTIVE_STATUSES.include?(status) expires_at.nil? || expires_at > Time.current diff --git a/db/migrate/20260728010001_add_is_admin_to_users.rb b/db/migrate/20260728010001_add_is_admin_to_users.rb deleted file mode 100644 index 613b7c69..00000000 --- a/db/migrate/20260728010001_add_is_admin_to_users.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddIsAdminToUsers < ActiveRecord::Migration[7.1] - def change - add_column :users, :is_admin, :boolean, default: false, null: false - end -end diff --git a/db/schema.rb b/db/schema.rb index eebe7517..2e4925a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_07_28_010001) do +ActiveRecord::Schema[7.1].define(version: 2026_07_21_150618) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1059,7 +1059,6 @@ t.string "suspended_reason" t.boolean "is_private", default: false, null: false t.datetime "last_management_notice_read_at" - t.boolean "is_admin", default: false, null: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["deleted_at"], name: "index_users_on_deleted_at" t.index ["email"], name: "index_users_on_email", unique: true diff --git a/lib/tasks/pro.rake b/lib/tasks/pro.rake index fc620fb2..182234ad 100644 --- a/lib/tasks/pro.rake +++ b/lib/tasks/pro.rake @@ -8,14 +8,4 @@ namespace :pro do task pro_expiring_reminder: :environment do ProExpiringReminderJob.perform_now end - - desc '指定メールアドレスのユーザーを admin にする(development 限定、強制 Pro モード用)' - task :make_admin, [:email] => :environment do |_task, args| - raise 'production では実行できません' if Rails.env.production? - raise 'Usage: rails pro:make_admin[email@example.com]' if args.email.blank? - - user = User.find_by!(email: args.email) - user.update!(is_admin: true) - puts "#{user.email} を admin にしました(development 環境で強制 Pro モードが有効になります)" - end end diff --git a/spec/lib/tasks/pro_rake_spec.rb b/spec/lib/tasks/pro_rake_spec.rb index 25dc8b12..50f643aa 100644 --- a/spec/lib/tasks/pro_rake_spec.rb +++ b/spec/lib/tasks/pro_rake_spec.rb @@ -30,29 +30,4 @@ expect(ProExpiringReminderJob).to have_received(:perform_now) end end - - describe 'pro:make_admin' do - let(:task) { Rake::Task['pro:make_admin'] } - let(:user) { create(:user, is_admin: false) } - - before { task.reenable } - - it '指定したメールアドレスのユーザーを is_admin: true にする' do - task.invoke(user.email) - expect(user.reload.is_admin).to be true - end - - it 'email が空のときは例外を送出する' do - expect { task.invoke }.to raise_error(/Usage/) - end - - it '存在しないメールアドレスのときは例外を送出する' do - expect { task.invoke('unknown@example.com') }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'production 環境では例外を送出する' do - allow(Rails.env).to receive(:production?).and_return(true) - expect { task.invoke(user.email) }.to raise_error('production では実行できません') - end - end end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb index e72c5521..bc3ef9f4 100644 --- a/spec/models/subscription_spec.rb +++ b/spec/models/subscription_spec.rb @@ -46,31 +46,6 @@ subscription = build(:subscription, :active, expires_at: nil) expect(subscription.pro_active?).to be true end - - context 'when development 環境の admin ユーザー(強制 Pro モード)' do - it 'returns true for a free status admin user in development' do - allow(Rails.env).to receive(:development?).and_return(true) - admin = create(:user, is_admin: true) - subscription = build(:subscription, :free, owner: admin) - - expect(subscription.pro_active?).to be true - end - - it 'returns false for a free status admin user outside development' do - admin = create(:user, is_admin: true) - subscription = build(:subscription, :free, owner: admin) - - expect(subscription.pro_active?).to be false - end - - it 'returns false for a free status non-admin user in development' do - allow(Rails.env).to receive(:development?).and_return(true) - non_admin = create(:user, is_admin: false) - subscription = build(:subscription, :free, owner: non_admin) - - expect(subscription.pro_active?).to be false - end - end end describe '#in_trial?' do From 854f921b07e0027db430e1b57f9d5ec1e39fc36b Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 22:02:33 +0900 Subject: [PATCH 14/19] =?UTF-8?q?Change:=20=E3=82=AB=E3=83=AC=E3=83=B3?= =?UTF-8?q?=E3=83=80=E3=83=BC=E4=BF=AF=E7=9E=B0=E3=81=AE=E7=84=A1=E6=96=99?= =?UTF-8?q?=E9=96=B2=E8=A6=A7=E7=AF=84=E5=9B=B2=E3=82=92=E5=89=8D=E5=BE=8C?= =?UTF-8?q?15=E6=97=A5=E3=81=8B=E3=82=89=E5=89=8D=E5=BE=8C3=E3=83=B6?= =?UTF-8?q?=E6=9C=88=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前後15日は狭すぎるとの判断で、今日を中心に前後3ヶ月(月単位)へ広げる。 月末月初での日数ズレを避けるためActiveSupportの3.monthsで計算する。 --- app/controllers/api/v2/plans_controller.rb | 8 ++++---- spec/requests/api/v2/plans_spec.rb | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb index d0f0d4b8..a99d94ff 100644 --- a/app/controllers/api/v2/plans_controller.rb +++ b/app/controllers/api/v2/plans_controller.rb @@ -6,8 +6,8 @@ module V2 class PlansController < Api::V2::ApplicationController before_action :authenticate_api_v1_user! - # 無料ユーザーのカレンダー俯瞰は「直近月中心」に閲覧範囲を絞る(前後15日)。 - FREE_CALENDAR_WINDOW_DAYS = 15 + # 無料ユーザーのカレンダー俯瞰は「直近月中心」に閲覧範囲を絞る(前後3ヶ月)。 + FREE_CALENDAR_WINDOW_MONTHS = 3 def by_date date = parse_date(params[:date]) @@ -26,8 +26,8 @@ def calendar unless current_api_v1_user.has_entitlement?('schedule_calendar_full_history') today = Time.find_zone('Asia/Tokyo').today - from = [from, today - FREE_CALENDAR_WINDOW_DAYS].max - to = [to, today + FREE_CALENDAR_WINDOW_DAYS].min + from = [from, today - FREE_CALENDAR_WINDOW_MONTHS.months].max + to = [to, today + FREE_CALENDAR_WINDOW_MONTHS.months].min end entries = (from..to).flat_map do |date| diff --git a/spec/requests/api/v2/plans_spec.rb b/spec/requests/api/v2/plans_spec.rb index 5bce3c7f..ece4255c 100644 --- a/spec/requests/api/v2/plans_spec.rb +++ b/spec/requests/api/v2/plans_spec.rb @@ -96,9 +96,9 @@ end context '無料ユーザーの閲覧範囲(直近月中心)' do - it '前後15日を超える未来の予定はクランプされて含まれない' do + it '前後3ヶ月を超える未来の予定はクランプされて含まれない' do today = Time.zone.today - far_future = today + 40 + far_future = today + 4.months create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) get '/api/v2/plans/calendar', @@ -112,7 +112,7 @@ it 'schedule_calendar_full_historyを持つProユーザーはクランプされない' do make_pro(user) today = Time.zone.today - far_future = today + 40 + far_future = today + 4.months create(:schedule, user:, title: '遠い未来の予定', days_of_week: nil, planned_on: far_future) get '/api/v2/plans/calendar', From d165d88ce31a9dc187a2aac29bc9f77343ccf411 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 22:43:21 +0900 Subject: [PATCH 15/19] =?UTF-8?q?Docs:=20=E3=83=9E=E3=82=A4=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E6=88=BB?= =?UTF-8?q?=E3=81=99=E9=9A=9B=E3=81=ABdb:schema:load=E3=81=A7=E9=96=8B?= =?UTF-8?q?=E7=99=BA=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92=E5=85=A8=E5=89=8A?= =?UTF-8?q?=E9=99=A4=E3=81=97=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E6=B3=A8?= =?UTF-8?q?=E6=84=8F=E6=9B=B8=E3=81=8D=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_adminカラムのマイグレーションrevert時にdb:schema:loadを実行し、 開発環境の全データ(ユーザー・Flipperフラグ設定等)を消してしまった実例を踏まえ、 特定マイグレーションのみを戻すdb:rollback/db:migrate:downを使うこと、 データ削除を伴う操作は事前に確認することを明記する。 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 70c87e59..0aa3d722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,12 @@ docker compose exec back bundle exec rails db:seed # シードデータ投入 docker compose exec back bundle exec rails routes # ルーティング確認 ``` +### マイグレーションを戻すとき + +- **`rails db:schema:load` は開発環境のデータを全て削除する**(`schema.rb` の `force: :cascade` で全テーブルがdrop&再作成される)。マイグレーションを1つ戻したいだけの場合には絶対に使わない +- 特定のマイグレーションだけを戻したい場合は `rails db:rollback` または `rails db:migrate:down VERSION=xxxxx` を使う。これなら他のテーブル・データには影響しない +- データを削除しうるDB操作(`schema:load`、`db:reset`、`db:drop` 等)を実行する前は、必ず内容を説明してユーザーに確認する + ## テスト ```bash From 78a33d9408ec46529c26635f475e2d0d07eff198 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 22:46:54 +0900 Subject: [PATCH 16/19] =?UTF-8?q?Fix:=20=E7=B7=B4=E7=BF=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=8C=E7=84=A1=E3=81=84=E3=83=A6=E3=83=BC=E3=82=B6?= =?UTF-8?q?=E3=83=BC=E3=81=AB=E7=B7=B4=E7=BF=92=E3=81=A8=E6=88=90=E7=B8=BE?= =?UTF-8?q?=E3=81=AE=E7=9B=B8=E9=96=A2=E3=82=A4=E3=83=B3=E3=82=B5=E3=82=A4?= =?UTF-8?q?=E3=83=88=E3=81=8C=E8=AA=A4=E3=81=A3=E3=81=A6=E6=96=AD=E5=AE=9A?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C=E3=82=8B=E4=B8=8D=E5=85=B7?= =?UTF-8?q?=E5=90=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試合記録のみで練習を一度もしていないユーザーで、以下2つの独立した問題により 実際には存在しない「傾向」が断定カードとして表示されていた。 1. 素振り本数のような入力値が全週0(変動が無い)場合でも上位群/下位群に 分割してしまい、意味の無い差分をあたかも傾向であるかのように表示していた。 入力値に変動が無いペアは非断定カードにする。 2. 「練習した日数」の判定にintensity_level(草・Streak向けの定義で試合のみの日も L4として含む)を使っていたため、試合しか記録していない日も「練習した日」として カウントされていた。practice_menu_countまたはtotal_swing_countが実際に 正の値を持つ日だけを練習日として数えるようにする。 --- app/services/insights/correlation_builder.rb | 9 +++++- .../insights/correlation_builder_spec.rb | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/services/insights/correlation_builder.rb b/app/services/insights/correlation_builder.rb index 01286a6d..1a469535 100644 --- a/app/services/insights/correlation_builder.rb +++ b/app/services/insights/correlation_builder.rb @@ -41,6 +41,9 @@ def custom_card(combination) def build_card(spec) paired = paired_weeks(spec[:input_series], spec[:metric_series]) return insufficient_card(spec, paired.size) if paired.size < MIN_PAIRED_WEEKS + # 入力値に変動が無い(例: 素振りを一度もしていないユーザーは全週 0)場合、 + # 上位群/下位群への分割自体に意味が無く、出てくる差は入力とは無関係なノイズになる。 + return insufficient_card(spec, paired.size) if paired.pluck(:input).uniq.size < 2 low, high = split_by_input_median(paired) diff = mean(high.pluck(:metric)) - mean(low.pluck(:metric)) @@ -161,7 +164,11 @@ def accumulate_activity_inputs(inputs) logs = @user.activity_logs.where(activity_date: window_start..) logs.group_by { |log| log.activity_date.beginning_of_week }.each do |week_start, week_logs| inputs[week_start][:total_swings] = week_logs.sum(&:total_swing_count) - inputs[week_start][:practice_days] = week_logs.count { |log| log.intensity_level >= 1 } + # intensity_level は試合のみの日も L4 として含む(草・Streak向けの定義)ため、 + # ここでの「練習した日数」には使えない。練習メニュー実施 or 素振りがあった日だけを数える。 + inputs[week_start][:practice_days] = week_logs.count do |log| + log.practice_menu_count.positive? || log.total_swing_count.positive? + end end end diff --git a/spec/services/insights/correlation_builder_spec.rb b/spec/services/insights/correlation_builder_spec.rb index ad77b479..18029fdb 100644 --- a/spec/services/insights/correlation_builder_spec.rb +++ b/spec/services/insights/correlation_builder_spec.rb @@ -56,5 +56,37 @@ def record_week(offset_weeks, swings:, at_bats:, hits:) expect(custom[:key]).to eq("custom_#{combo.id}") expect(custom[:title]).to eq('睡眠時間とOPS') end + + # 試合のみ記録し練習を一度もしていないユーザーが対象。 + # game_result 保存時に activity_log が作られるが、practice_menu_count/total_swing_count は + # 0 のまま、intensity_level だけ試合により L4 になる。 + def record_game_only_week(offset_weeks, at_bats:, hits:) + day = week_start(offset_weeks) + game = create(:game_result, user:) + game.match_result.update!(date_and_time: Time.utc(day.year, day.month, day.day, 12)) + create(:batting_average, user:, game_result: game, at_bats:, hit: hits, + two_base_hit: 0, three_base_hit: 0, home_run: 0, strike_out: 0, plate_appearances: at_bats) + end + + it '練習を一度もしていないユーザーには素振り本数のカードを断定表示しない(入力値が全週0で変動が無いため)' do + record_game_only_week(0, at_bats: 4, hits: 3) + record_game_only_week(1, at_bats: 4, hits: 2) + record_game_only_week(2, at_bats: 4, hits: 0) + record_game_only_week(3, at_bats: 4, hits: 0) + + swings_card = described_class.new(user:).call.find { |card| card[:key] == 'swings_vs_ba' } + expect(swings_card[:sufficient]).to be false + expect(swings_card[:direction]).to eq('unknown') + end + + it '試合のみの日は「練習した日数」に含めない' do + record_game_only_week(0, at_bats: 4, hits: 3) + record_game_only_week(1, at_bats: 4, hits: 2) + record_game_only_week(2, at_bats: 4, hits: 0) + record_game_only_week(3, at_bats: 4, hits: 0) + + practice_days_card = described_class.new(user:).call.find { |card| card[:key] == 'practice_days_vs_ops' } + expect(practice_days_card[:sufficient]).to be false + end end end From bb7baeef3074316320e978537b82efbff7b0c743 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 23:01:43 +0900 Subject: [PATCH 17/19] =?UTF-8?q?Fix:=20SubscriberSync=E3=81=8CPlanCatalog?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E9=8C=B2=E3=81=AEproduct=5Fid/store=E3=82=92?= =?UTF-8?q?=E7=84=A1=E8=A6=96=E3=81=97=E3=81=A6=E9=9D=99=E3=81=8B=E3=81=AB?= =?UTF-8?q?nil=E3=82=92=E4=BF=9D=E5=AD=98=E3=81=99=E3=82=8B=E4=B8=8D?= =?UTF-8?q?=E5=85=B7=E5=90=88=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook側のBaseHandler#unknown_product?は未登録のproduct_id/storeが来ると 更新自体をスキップしSentryへ警告するが、SubscriberSyncには同等のガードが無く、 plan_type/platformにnilを静かに保存していた。RevenueCat側に新SKUが追加されて PlanCatalogの追従が漏れた場合、Webhook経由では検知できてもsync経由では 気付けない非対称が生まれていたため、同じガードを追加する。 --- app/services/revenue_cat/subscriber_sync.rb | 17 ++++++++++++++ .../revenue_cat/subscriber_sync_spec.rb | 22 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/services/revenue_cat/subscriber_sync.rb b/app/services/revenue_cat/subscriber_sync.rb index b4f76e76..25e7e025 100644 --- a/app/services/revenue_cat/subscriber_sync.rb +++ b/app/services/revenue_cat/subscriber_sync.rb @@ -30,10 +30,27 @@ def call def apply_entitlement(subscription, subscriber, entitlement) product_id = entitlement['product_identifier'] subscription_detail = subscriber.dig('subscriptions', product_id) || {} + store = subscription_detail['store'].to_s.upcase + return if unknown_product?(product_id, store) subscription.update!(attributes_for(subscription, product_id, subscription_detail, entitlement)) end + # PlanCatalogに未登録のproduct_id/storeが来た場合、plan_type/platformにnilを + # 静かに保存してしまうとWebhookのHandler群(unknown_product?で更新自体をスキップする) + # と挙動が非対称になる。Webhookと同様に更新を丸ごとスキップしSentryへ警告する。 + def unknown_product?(product_id, store) + plan_type_missing = PlanCatalog.plan_type_from(product_id).nil? + platform_missing = PlanCatalog.platform_from(store).nil? + return false unless plan_type_missing || platform_missing + + Sentry.capture_message( + "RevenueCat sync: unknown product_id=#{product_id.inspect} or store=#{store.inspect}", + level: :warning + ) + true + end + def attributes_for(subscription, product_id, subscription_detail, entitlement) expires_at = parse_time(entitlement['expires_date']) effective_expires_at = grace_expires_at(entitlement, subscription_detail) || expires_at diff --git a/spec/services/revenue_cat/subscriber_sync_spec.rb b/spec/services/revenue_cat/subscriber_sync_spec.rb index 9c9f7db4..74591a2a 100644 --- a/spec/services/revenue_cat/subscriber_sync_spec.rb +++ b/spec/services/revenue_cat/subscriber_sync_spec.rb @@ -107,6 +107,28 @@ def stub_entitlement(entitlement_overrides: {}, subscription_overrides: {}) expect(subscription.cancelled_at).to be_present end end + + it 'PlanCatalogに未登録のproduct_idの場合は更新をスキップしSentryへ警告する' do + stub_entitlement(entitlement_overrides: { 'product_identifier' => 'unknown_product' }) + allow(Sentry).to receive(:capture_message) + + subscription = described_class.new(user).call + + expect(subscription.status).to eq('free') + expect(subscription.plan_type).to be_nil + expect(Sentry).to have_received(:capture_message).with(/unknown product_id/, level: :warning) + end + + it 'PlanCatalogに未登録のstoreの場合は更新をスキップしSentryへ警告する' do + stub_entitlement(subscription_overrides: { 'store' => 'unknown_store' }) + allow(Sentry).to receive(:capture_message) + + subscription = described_class.new(user).call + + expect(subscription.status).to eq('free') + expect(subscription.platform).to be_nil + expect(Sentry).to have_received(:capture_message).with(/unknown product_id/, level: :warning) + end end end end From 7770a58a64fd40061b0578c7ecdacc57f4817112 Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 23:03:08 +0900 Subject: [PATCH 18/19] =?UTF-8?q?Fix:=20NETWORK=5FERRORS=E3=81=ABNet::Writ?= =?UTF-8?q?eTimeout=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現状write_timeoutオプションを渡していないため実害は無いが、将来 write_timeoutを設定した際にこのリストへの追加が漏れやすいため先に含めておく。 --- app/services/revenue_cat/subscriber_client.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/revenue_cat/subscriber_client.rb b/app/services/revenue_cat/subscriber_client.rb index 5df7b2f7..38d97d88 100644 --- a/app/services/revenue_cat/subscriber_client.rb +++ b/app/services/revenue_cat/subscriber_client.rb @@ -15,7 +15,8 @@ class RequestFailedError < StandardError; end # 呼び出し側(SyncController)が一律 bad_gateway として扱えるようにする。 NETWORK_ERRORS = [ Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, OpenSSL::SSL::SSLError, - Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, + Net::ProtocolError ].freeze # @param app_user_id [String] RevenueCatのapp_user_id(このアプリではuser.id.to_sを使う) From 7f665ea26b14eb6c2ffb60dca124afcf36ca3bfc Mon Sep 17 00:00:00 2001 From: Ippei Shimizu Date: Tue, 28 Jul 2026 23:03:40 +0900 Subject: [PATCH 19/19] =?UTF-8?q?Docs:=20secret=5Fkey=E3=81=AEENV.fetch?= =?UTF-8?q?=E3=81=AB=E3=83=87=E3=83=95=E3=82=A9=E3=83=AB=E3=83=88=E5=80=A4?= =?UTF-8?q?=E3=82=92=E8=A8=AD=E3=81=91=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84?= =?UTF-8?q?=E6=84=8F=E5=9B=B3=E3=82=92=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=A7=E6=98=8E=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook側のENV.fetch('REVENUECAT_WEBHOOK_SECRET', nil)と違いデフォルト値が 無く未設定時はKeyErrorになる点についてレビューで確認を求められたため、 意図的な設計判断であることをコメントで残す。 --- app/services/revenue_cat/subscriber_client.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/revenue_cat/subscriber_client.rb b/app/services/revenue_cat/subscriber_client.rb index 38d97d88..8153616a 100644 --- a/app/services/revenue_cat/subscriber_client.rb +++ b/app/services/revenue_cat/subscriber_client.rb @@ -45,6 +45,8 @@ def fetch_subscriber(app_user_id) private + # デフォルト値を持たせない意図: 秘密鍵の設定漏れは一過性のネットワーク障害ではなく + # デプロイ設定のミスのため、502(RequestFailedError)で揉み消さずKeyErrorで即座に気付けるようにする。 def secret_key ENV.fetch('REVENUECAT_SECRET_API_KEY') end