Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f005872
Add: development環境のadminユーザーに強制Proモードを実装
ippei-shimizu Jul 27, 2026
ae9d2db
Fix: goal更新APIでmetric_key/comparison_type/practice_menu_idを変更不可にする
ippei-shimizu Jul 27, 2026
86ab4c3
Fix: シーズン粒度推移でseason_id絞り込みと併用すると1シーズンに縮退する不具合を修正
ippei-shimizu Jul 28, 2026
662ead9
Add: /pro/syncをRevenueCat REST APIとの実同期に置き換え
ippei-shimizu Jul 28, 2026
1296373
Fix: is_adminが他ユーザーのプロフィール表示・検索APIで漏洩していた問題を修正
ippei-shimizu Jul 28, 2026
25d7389
Add: 素振りカウンターのバックグラウンド継続実行entitlementを追加
ippei-shimizu Jul 28, 2026
a1559f4
Add: カレンダー俯瞰の閲覧範囲にPro/無料の出し分けを実装
ippei-shimizu Jul 28, 2026
9bac52e
Add: 防御率推移(EraTrendChart)にシーズン粒度・自己ベスト強調を実装
ippei-shimizu Jul 28, 2026
22de146
Add: 達成バッジ(goal_badges)を閲覧するAPIを追加
ippei-shimizu Jul 28, 2026
d369278
Fix: SubscriberSyncがグレース期間中にexpires_atへ生の期限を保存しpro_active?を壊す不具合を修正
ippei-shimizu Jul 28, 2026
46085f7
Fix: SubscriberClientのタイムアウト・接続エラーが502ハンドリングを迂回する不具合を修正
ippei-shimizu Jul 28, 2026
476af36
Revert "Fix: is_adminが他ユーザーのプロフィール表示・検索APIで漏洩していた問題を修正"
ippei-shimizu Jul 28, 2026
7036a1b
Revert "Add: development環境のadminユーザーに強制Proモードを実装"
ippei-shimizu Jul 28, 2026
854f921
Change: カレンダー俯瞰の無料閲覧範囲を前後15日から前後3ヶ月に変更
ippei-shimizu Jul 28, 2026
d165d88
Docs: マイグレーションを戻す際にdb:schema:loadで開発データを全削除しないよう注意書きを追加
ippei-shimizu Jul 28, 2026
78a33d9
Fix: 練習記録が無いユーザーに練習と成績の相関インサイトが誤って断定表示される不具合を修正
ippei-shimizu Jul 28, 2026
bb7baee
Fix: SubscriberSyncがPlanCatalog未登録のproduct_id/storeを無視して静かにnilを保存する不具…
ippei-shimizu Jul 28, 2026
7770a58
Fix: NETWORK_ERRORSにNet::WriteTimeoutを追加
ippei-shimizu Jul 28, 2026
7f665ea
Docs: secret_keyのENV.fetchにデフォルト値を設けていない意図をコメントで明記
ippei-shimizu Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions app/controllers/api/v1/pro/sync_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/api/v2/goal_badges_controller.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions app/controllers/api/v2/goals_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/controllers/api/v2/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ module V2
class PlansController < Api::V2::ApplicationController
before_action :authenticate_api_v1_user!

# 無料ユーザーのカレンダー俯瞰は「直近月中心」に閲覧範囲を絞る(前後3ヶ月)。
FREE_CALENDAR_WINDOW_MONTHS = 3

def by_date
date = parse_date(params[:date])
return render json: { error: 'date が不正です' }, status: :unprocessable_entity if date.nil?
Expand All @@ -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_MONTHS.months].max
to = [to, today + FREE_CALENDAR_WINDOW_MONTHS.months].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 }
Expand Down
20 changes: 18 additions & 2 deletions app/controllers/api/v2/stats_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,7 +76,7 @@ def batting_trend
end

render json: Stats::BattingTrendAggregator.new(
**aggregator_params, granularity: params[:granularity]
**season_aware_params, granularity: params[:granularity]
).call
end

Expand Down Expand Up @@ -109,6 +116,15 @@ def aggregator_params
}
end

# granularity=season はシーズン跨ぎで全シーズンを比較する機能のため、
# season_id による単一シーズン絞り込みと併用すると1シーズンに縮退してしまう。
# 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)
end

# batting / pitching テーブル用は period (mode) を追加で受け取り、
# match_type は使わない(テーブルサービスのインターフェースに合わせる)。
def table_params
Expand Down
2 changes: 2 additions & 0 deletions app/models/concerns/entitlement.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ module Entitlement
'manual_metric_goals', # 自由指標(手動更新)の目標設定(無料は利用不可)
'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', # カウント別の打率(初球・有利・追い込み等)
Expand Down
9 changes: 9 additions & 0 deletions app/serializers/v2/goal_badge_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion app/services/insights/correlation_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions app/services/revenue_cat/subscriber_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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

# タイムアウト・接続断・TLSエラー等はRequestFailedErrorへ変換し、
# 呼び出し側(SyncController)が一律 bad_gateway として扱えるようにする。
NETWORK_ERRORS = [
Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, OpenSSL::SSL::SSLError,
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を使う)
# @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, 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

# デフォルト値を持たせない意図: 秘密鍵の設定漏れは一過性のネットワーク障害ではなく
# デプロイ設定のミスのため、502(RequestFailedError)で揉み消さずKeyErrorで即座に気付けるようにする。
def secret_key
ENV.fetch('REVENUECAT_SECRET_API_KEY')
end
end
end
105 changes: 105 additions & 0 deletions app/services/revenue_cat/subscriber_sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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) || {}
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
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,
# 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,
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
Loading
Loading