discourse/plugins/discourse-patreon/lib/api_version/v2.rb
Rafael dos Santos Silva bbc2fed22b
FIX: Patreon API pagination bugs causing missing pledges (#39058)
## Summary

- **Nil patron data crash**: Patreon V1 API can return pledge entries
where `relationships.patron.data` is null (deleted/deactivated
accounts). This caused `extract` to raise `NoMethodError`, preventing
`save!` from completing — leaving the pledge store stuck at stale data
(exactly 100 pledges from a previous sync). Fixed with `dig` for safe
nil traversal, skipping entries with missing IDs.
- **Double base URL**: Pagination `next` links from Patreon are absolute
URLs, but `Api.get` was prepending the base URL again
(`https://api.patreon.comhttps://api.patreon.com/...`). Fixed by
detecting absolute URIs and using them directly.
- **Non-incremental saves**: All pages were collected before a single
`save!`. If any page failed, nothing was persisted. Now each page is
saved incrementally — first page clears stale data, subsequent pages
append. Also handles the edge case where the API returns empty data by
explicitly clearing the store so ex-patrons don't keep group access.
2026-04-01 17:18:51 -03:00

149 lines
4.4 KiB
Ruby

# frozen_string_literal: true
module Patreon
module ApiVersion
module V2
BASE_URL = "https://www.patreon.com"
CAMPAIGN_FIELDS = "fields%5Bcampaign%5D=created_at,name,patron_count"
TIER_FIELDS = "fields%5Btier%5D=title,amount_cents,created_at"
MEMBER_FIELDS =
"fields%5Bmember%5D=full_name,last_charge_date,last_charge_status,currently_entitled_amount_cents,patron_status,email"
USER_FIELDS = "fields%5Buser%5D=email,full_name"
def self.campaign_data_url
"/api/oauth2/v2/campaigns?include=tiers,creator&#{CAMPAIGN_FIELDS}&#{TIER_FIELDS}"
end
def self.api_base_url
BASE_URL
end
def self.token_base_url
BASE_URL
end
def self.token_path
"/api/oauth2/token"
end
def self.oauth_token_url
"#{BASE_URL}/api/oauth2/token"
end
def self.oauth_authorize_params
{
response_type: "code",
scope: "identity identity[email] campaigns campaigns.members campaigns.members[email]",
}
end
def self.oauth_identity_url
"#{BASE_URL}/api/oauth2/v2/identity?fields%5Buser%5D=email,full_name,is_email_verified"
end
def self.members_data_url(campaign_id, cursor = nil)
url =
"/api/oauth2/v2/campaigns/#{campaign_id}/members?include=currently_entitled_tiers,user&#{MEMBER_FIELDS}&#{USER_FIELDS}&#{TIER_FIELDS}&page%5Bcount%5D=1000"
url += "&page%5Bcursor%5D=#{CGI.escape(cursor)}" if cursor.present?
url
end
def self.parse_campaigns(response)
rewards = {}
campaign_ids = response["data"].map { |campaign| campaign["id"] }
(response["included"] || []).each do |entry|
if entry["type"] == "tier"
id = entry["id"]
rewards[id] = entry["attributes"]
rewards[id]["id"] = id
end
end
{ rewards: rewards, campaign_ids: campaign_ids }
end
def self.pull_pledges!(campaign_data)
campaign_ids = campaign_data[:campaign_ids]
is_first_page = true
campaign_ids.each do |campaign_id|
cursor = nil
loop do
response = Patreon::Api.get(members_data_url(campaign_id, cursor))
if response.blank? || response["data"].blank?
Patreon::Pledge.save!([], false, adapter: self) if is_first_page
break
end
Patreon::Pledge.save!([response], !is_first_page, adapter: self)
is_first_page = false
cursor = response.dig("meta", "pagination", "cursors", "next")
break if cursor.blank?
end
end
end
def self.extract(member_data)
pledges, declines, reward_users, users = {}, {}, {}, {}
if member_data && member_data["data"].present?
member_data["data"] = [member_data["data"]] unless member_data["data"].kind_of?(Array)
member_data["data"].each do |entry|
next unless entry["type"] == "member"
patron_id = entry["relationships"]["user"]["data"]["id"]
attrs = entry["attributes"]
(entry.dig("relationships", "currently_entitled_tiers", "data") || []).each do |tier|
(reward_users[tier["id"]] ||= []) << patron_id
end
pledges[patron_id] = attrs["currently_entitled_amount_cents"]
declines[patron_id] = attrs["last_charge_date"] if attrs["last_charge_status"] ==
"Declined"
end
(member_data["included"] || []).each do |entry|
if entry["type"] == "user" && entry["attributes"]["email"].present?
users[entry["id"]] = entry["attributes"]["email"].downcase
end
end
end
[pledges, declines, reward_users, users]
end
def self.delete_pledge_data(entry, reward_users)
rel = entry["relationships"]
patron_id = rel["user"]["data"]["id"]
(rel.dig("currently_entitled_tiers", "data") || []).each do |tier|
(reward_users[tier["id"]] || []).reject! { |i| i == patron_id }
end
patron_id
end
def self.get_patreon_id(data)
data["data"]["relationships"]["user"]["data"]["id"]
end
def self.webhook_triggers
%w[
members:create
members:update
members:delete
members:pledge:create
members:pledge:update
members:pledge:delete
]
end
end
end
end