You've already forked AstralRinth
Pride 26 campaign backend integration (#6254)
* wip: pride 2026 webhooks and stuff * setup webhook and link to user * fix up code * improve donation resolution * Pride 26 campaign * idempotency * wip: tiltify * fix * redis caching * add num donators * fix * Revert openapi * Prepare * improve oauth token gen code
This commit is contained in:
@@ -102,6 +102,9 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/
|
||||
TREMENDOUS_API_KEY=none
|
||||
TREMENDOUS_PRIVATE_KEY=none
|
||||
TREMENDOUS_CAMPAIGN_ID=none
|
||||
TILTIFY_CLIENT_ID=
|
||||
TILTIFY_CLIENT_SECRET=
|
||||
TILTIFY_PRIDE_26_CAMPAIGN_ID=
|
||||
|
||||
HCAPTCHA_SECRET=none
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/
|
||||
TREMENDOUS_API_KEY=none
|
||||
TREMENDOUS_PRIVATE_KEY=none
|
||||
TREMENDOUS_CAMPAIGN_ID=none
|
||||
TILTIFY_CLIENT_ID=
|
||||
TILTIFY_CLIENT_SECRET=
|
||||
TILTIFY_PRIDE_26_CAMPAIGN_ID=
|
||||
|
||||
HCAPTCHA_SECRET=none
|
||||
|
||||
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM campaign_donations WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2248f0698a4195e8f1309d89d9ea78aeb06db699c469cdef0719486d9b938647"
|
||||
}
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id)\n values ($1, $2::text::uuid, $3, $4, $5, $6)\n on conflict (tiltify_event_id) do nothing\n returning id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Timestamptz",
|
||||
"Numeric",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2f883a2641f3276be6e57f7fcce0f1c7cc2ba03e09094750dc501df45d8421d5"
|
||||
}
|
||||
+23
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n AND campaign_donations.amount_usd > 5\n ) AS campaign_pride_26,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -50,81 +50,86 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "campaign_pride_26",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "github_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"ordinal": 11,
|
||||
"name": "discord_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"ordinal": 12,
|
||||
"name": "gitlab_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"ordinal": 13,
|
||||
"name": "google_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"ordinal": 14,
|
||||
"name": "steam_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 15,
|
||||
"name": "microsoft_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"ordinal": 16,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"ordinal": 17,
|
||||
"name": "password",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"ordinal": 18,
|
||||
"name": "totp_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"ordinal": 19,
|
||||
"name": "paypal_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"ordinal": 20,
|
||||
"name": "paypal_country",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"ordinal": 21,
|
||||
"name": "paypal_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"ordinal": 22,
|
||||
"name": "venmo_handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"ordinal": 23,
|
||||
"name": "stripe_customer_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"ordinal": 24,
|
||||
"name": "allow_friend_requests",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 25,
|
||||
"name": "is_subscribed_to_newsletter",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -145,6 +150,7 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
@@ -163,5 +169,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
|
||||
"hash": "9139ada08ea017859b2928d2776f878ce9531daa7556fb0b4d2a467cafeaa130"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
create table campaign_donations (
|
||||
id bigint primary key,
|
||||
tiltify_event_id uuid not null unique,
|
||||
raw_data jsonb not null,
|
||||
donated_at timestamptz not null,
|
||||
amount_usd numeric(96, 48),
|
||||
user_id bigint references users(id)
|
||||
);
|
||||
create index campaign_donations_user_amount_donated_at_idx
|
||||
on campaign_donations (user_id, amount_usd, donated_at);
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
|
||||
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
|
||||
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
|
||||
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
|
||||
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
|
||||
ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
|
||||
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
|
||||
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
|
||||
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
|
||||
ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
|
||||
TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -164,6 +164,10 @@ db_id_interface!(
|
||||
ChargeId,
|
||||
generator: generate_charge_id @ "charges",
|
||||
);
|
||||
db_id_interface!(
|
||||
CampaignDonationId,
|
||||
generator: generate_campaign_donation_id @ "campaign_donations",
|
||||
);
|
||||
db_id_interface!(
|
||||
CollectionId,
|
||||
generator: generate_collection_id @ "collections",
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct DBUser {
|
||||
pub created: DateTime<Utc>,
|
||||
pub role: String,
|
||||
pub badges: Badges,
|
||||
#[serde(default)]
|
||||
pub campaign_pride_26: Option<DateTime<Utc>>,
|
||||
|
||||
pub allow_friend_requests: bool,
|
||||
|
||||
@@ -180,6 +182,12 @@ impl DBUser {
|
||||
SELECT id, email,
|
||||
avatar_url, raw_avatar_url, username, bio,
|
||||
created, role, badges,
|
||||
(
|
||||
SELECT MAX(campaign_donations.donated_at)
|
||||
FROM campaign_donations
|
||||
WHERE campaign_donations.user_id = users.id
|
||||
AND campaign_donations.amount_usd > 5
|
||||
) AS campaign_pride_26,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||
@@ -208,6 +216,7 @@ impl DBUser {
|
||||
created: u.created,
|
||||
role: u.role,
|
||||
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
|
||||
campaign_pride_26: u.campaign_pride_26,
|
||||
password: u.password,
|
||||
paypal_id: u.paypal_id,
|
||||
paypal_country: u.paypal_country,
|
||||
|
||||
@@ -295,6 +295,9 @@ vars! {
|
||||
DELPHI_SLACK_WEBHOOK: String = "";
|
||||
|
||||
TREMENDOUS_CAMPAIGN_ID: String = "none";
|
||||
TILTIFY_CLIENT_ID: String = "";
|
||||
TILTIFY_CLIENT_SECRET: String = "";
|
||||
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
|
||||
|
||||
// server pinging
|
||||
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::util::anrok;
|
||||
use crate::util::archon::ArchonClient;
|
||||
use crate::util::http::HttpClient;
|
||||
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
|
||||
use crate::util::tiltify::TiltifyClient;
|
||||
use sync::friends::handle_pubsub;
|
||||
|
||||
pub mod auth;
|
||||
@@ -73,6 +74,7 @@ pub struct LabrinthConfig {
|
||||
pub archon_client: web::Data<ArchonClient>,
|
||||
pub gotenberg_client: GotenbergClient,
|
||||
pub http_client: web::Data<HttpClient>,
|
||||
pub tiltify_client: web::Data<TiltifyClient>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -108,6 +110,8 @@ pub fn app_setup(
|
||||
let scheduler = scheduler::Scheduler::new();
|
||||
|
||||
let http_client = web::Data::new(HttpClient::new());
|
||||
let tiltify_client =
|
||||
web::Data::new(TiltifyClient::new(http_client.get_ref().clone()));
|
||||
{
|
||||
let pool_ref = pool.clone();
|
||||
let http_ref = http_client.clone();
|
||||
@@ -312,6 +316,7 @@ pub fn app_setup(
|
||||
anrok_client,
|
||||
gotenberg_client,
|
||||
http_client,
|
||||
tiltify_client,
|
||||
archon_client: web::Data::new(
|
||||
ArchonClient::from_env()
|
||||
.expect("ARCHON_URL and PYRO_API_KEY must be set"),
|
||||
@@ -343,6 +348,7 @@ pub fn app_config(
|
||||
.app_data(labrinth_config.search_backend.clone())
|
||||
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
|
||||
.app_data(labrinth_config.http_client.clone())
|
||||
.app_data(labrinth_config.tiltify_client.clone())
|
||||
.app_data(labrinth_config.session_queue.clone())
|
||||
.app_data(labrinth_config.payouts_queue.clone())
|
||||
.app_data(labrinth_config.email_queue.clone())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use ariadne::ids::base62_id;
|
||||
|
||||
base62_id!(ChargeId);
|
||||
base62_id!(CampaignDonationId);
|
||||
base62_id!(CollectionId);
|
||||
base62_id!(FileId);
|
||||
base62_id!(ImageId);
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct User {
|
||||
pub created: DateTime<Utc>,
|
||||
pub role: Role,
|
||||
pub badges: Badges,
|
||||
pub campaigns: UserCampaigns,
|
||||
|
||||
pub auth_providers: Option<Vec<AuthProvider>>,
|
||||
pub email: Option<String>,
|
||||
@@ -72,6 +73,11 @@ pub struct User {
|
||||
pub github_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserCampaigns {
|
||||
pub pride_26: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserPayoutData {
|
||||
pub paypal_address: Option<String>,
|
||||
@@ -94,6 +100,9 @@ impl From<DBUser> for User {
|
||||
created: data.created,
|
||||
role: Role::from_string(&data.role),
|
||||
badges: data.badges,
|
||||
campaigns: UserCampaigns {
|
||||
pride_26: data.campaign_pride_26,
|
||||
},
|
||||
payout_data: None,
|
||||
auth_providers: None,
|
||||
has_password: None,
|
||||
@@ -142,6 +151,9 @@ impl User {
|
||||
created: db_user.created,
|
||||
role: Role::from_string(&db_user.role),
|
||||
badges: db_user.badges,
|
||||
campaigns: UserCampaigns {
|
||||
pride_26: db_user.campaign_pride_26,
|
||||
},
|
||||
auth_providers: Some(auth_providers),
|
||||
has_password: Some(db_user.password.is_some()),
|
||||
has_totp: Some(db_user.totp_secret.is_some()),
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
use actix_web::{get, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use reqwest::Method;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
PgPool, PgTransaction,
|
||||
models::{
|
||||
DBCampaignDonationId, DBUser, DBUserId,
|
||||
generate_campaign_donation_id,
|
||||
},
|
||||
redis::RedisPool,
|
||||
},
|
||||
env::ENV,
|
||||
models::payouts::TremendousForexResponse,
|
||||
queue::payouts::PayoutsQueue,
|
||||
routes::ApiError,
|
||||
util::{error::Context, http::HttpClient, tiltify::TiltifyClient},
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(tiltify_webhook).service(pride_26);
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct TiltifyWebhook {
|
||||
data: TiltifyData,
|
||||
meta: TiltifyMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct TiltifyData {
|
||||
amount_raised: AmountRaised,
|
||||
user: TiltifyUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct AmountRaised {
|
||||
currency: String,
|
||||
value: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct TiltifyUser {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct TiltifyMeta {
|
||||
attempted_at: DateTime<Utc>,
|
||||
event_type: String,
|
||||
generated_at: DateTime<Utc>,
|
||||
id: Uuid,
|
||||
subscription_source_id: Uuid,
|
||||
subscription_source_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CampaignInfo {
|
||||
total_donations_usd: Decimal,
|
||||
target_usd: Decimal,
|
||||
num_donators: usize,
|
||||
}
|
||||
|
||||
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info";
|
||||
const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 15 * 60;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyCampaignResponse {
|
||||
data: TiltifyCampaign,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyCampaign {
|
||||
goal: AmountRaised,
|
||||
total_amount_raised: AmountRaised,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyDonationResponse {
|
||||
data: Vec<TiltifyDonation>,
|
||||
metadata: TiltifyPaginationMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyDonation {
|
||||
donor_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyPaginationMetadata {
|
||||
after: Option<String>,
|
||||
}
|
||||
|
||||
struct CampaignDonation {
|
||||
id: DBCampaignDonationId,
|
||||
tiltify_event_id: Uuid,
|
||||
raw_data: serde_json::Value,
|
||||
donated_at: DateTime<Utc>,
|
||||
amount_usd: Option<Decimal>,
|
||||
user_id: Option<DBUserId>,
|
||||
}
|
||||
|
||||
impl CampaignDonation {
|
||||
async fn insert(
|
||||
&self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let user_id = self.user_id.map(|id| id.0);
|
||||
let inserted = sqlx::query!(
|
||||
"
|
||||
insert into campaign_donations (id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id)
|
||||
values ($1, $2::text::uuid, $3, $4, $5, $6)
|
||||
on conflict (tiltify_event_id) do nothing
|
||||
returning id
|
||||
",
|
||||
self.id.0,
|
||||
self.tiltify_event_id.to_string(),
|
||||
self.raw_data,
|
||||
self.donated_at,
|
||||
self.amount_usd,
|
||||
user_id,
|
||||
)
|
||||
.fetch_optional(transaction)
|
||||
.await
|
||||
.wrap_internal_err("inserting campaign donation")?;
|
||||
|
||||
Ok(inserted.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/webhook")]
|
||||
pub async fn tiltify_webhook(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
web::Json(raw_payload): web::Json<serde_json::Value>,
|
||||
) -> Result<(), ApiError> {
|
||||
// deserialize the JSON in the request handler, not in the params,
|
||||
// since if the JSON fails to deserialize then it's *our* fault,
|
||||
// not the caller's.
|
||||
let payload = TiltifyWebhook::deserialize(&raw_payload)
|
||||
.wrap_internal_err_with(|| {
|
||||
eyre!(
|
||||
"invalid Tiltify webhook payload schema\n{}",
|
||||
serde_json::to_string_pretty(&raw_payload)
|
||||
.expect("serializing should not fail")
|
||||
)
|
||||
})?;
|
||||
|
||||
// no matter what, we need to insert this donation record into the db
|
||||
// so we'll make one upfront
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("beginning transaction")?;
|
||||
let id = generate_campaign_donation_id(&mut transaction).await?;
|
||||
|
||||
let mut donation = CampaignDonation {
|
||||
id,
|
||||
tiltify_event_id: payload.meta.id,
|
||||
raw_data: raw_payload,
|
||||
donated_at: payload.meta.generated_at,
|
||||
amount_usd: None,
|
||||
user_id: None,
|
||||
};
|
||||
|
||||
let username = async {
|
||||
// then we can attempt user lookups
|
||||
let username = payload.data.user.username;
|
||||
let user = DBUser::get(&username, &**pool, &redis)
|
||||
.await
|
||||
.wrap_err("fetching user from database")?
|
||||
.wrap_err_with(|| {
|
||||
eyre!("got donation for user '{username}' which does not exist")
|
||||
})?;
|
||||
|
||||
donation.user_id = Some(user.id);
|
||||
eyre::Ok(username)
|
||||
}
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
warn!("Failed to resolve donation to Modrinth user: {err:?}")
|
||||
})
|
||||
.ok();
|
||||
|
||||
let amount_usd = async {
|
||||
// and insert value amount
|
||||
let amount_usd =
|
||||
amount_raised_usd(&payload.data.amount_raised, &payouts_queue)
|
||||
.await
|
||||
.wrap_err("failed to get donation amount")?;
|
||||
|
||||
donation.amount_usd = Some(amount_usd);
|
||||
eyre::Ok(amount_usd)
|
||||
}
|
||||
.await
|
||||
.inspect_err(|err| warn!("Failed to resolve donation amount: {err:?}"))
|
||||
.ok();
|
||||
|
||||
info!(
|
||||
"Resolved donation from {} for US${}",
|
||||
username.as_deref().unwrap_or("<unknown>"),
|
||||
amount_usd
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "<unknown>".to_string())
|
||||
);
|
||||
|
||||
let inserted = donation
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("inserting donation")?;
|
||||
|
||||
if !inserted {
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("committing duplicate donation transaction")?;
|
||||
info!("Ignoring duplicate Tiltify webhook {}", payload.meta.id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("committing transaction")?;
|
||||
|
||||
if let Some(user_id) = donation.user_id {
|
||||
DBUser::clear_caches(&[(user_id, username)], &redis)
|
||||
.await
|
||||
.wrap_internal_err("clearing user caches")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("/pride-26")]
|
||||
pub async fn pride_26(
|
||||
http: web::Data<HttpClient>,
|
||||
redis: web::Data<RedisPool>,
|
||||
tiltify: web::Data<TiltifyClient>,
|
||||
) -> Result<web::Json<CampaignInfo>, ApiError> {
|
||||
let campaign_id = &ENV.TILTIFY_PRIDE_26_CAMPAIGN_ID;
|
||||
let mut redis_connection = redis
|
||||
.connect()
|
||||
.await
|
||||
.wrap_internal_err("connecting to redis")?;
|
||||
|
||||
if let Some(cached) = redis_connection
|
||||
.get(CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id)
|
||||
.await
|
||||
.wrap_internal_err("getting cached campaign info")?
|
||||
{
|
||||
let campaign_info = serde_json::from_str::<CampaignInfo>(&cached)
|
||||
.wrap_internal_err("parsing cached campaign info")?;
|
||||
return Ok(web::Json(campaign_info));
|
||||
}
|
||||
|
||||
let access_token = tiltify
|
||||
.access_token()
|
||||
.await
|
||||
.wrap_internal_err("fetching Tiltify access token")?;
|
||||
let url = format!(
|
||||
"https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}",
|
||||
);
|
||||
let response = http
|
||||
.get(url)
|
||||
.bearer_auth(&access_token)
|
||||
.send()
|
||||
.await
|
||||
.wrap_internal_err("fetching campaign from Tiltify")?
|
||||
.error_for_status()
|
||||
.wrap_internal_err("fetching campaign from Tiltify")?
|
||||
.json::<TiltifyCampaignResponse>()
|
||||
.await
|
||||
.wrap_internal_err("parsing Tiltify response")?;
|
||||
|
||||
let raised_currency = &response.data.total_amount_raised.currency;
|
||||
if raised_currency != "USD" {
|
||||
return Err(ApiError::Internal(eyre!(
|
||||
"total amount raised is in {raised_currency}, must be USD"
|
||||
)));
|
||||
}
|
||||
|
||||
let goal_currency = &response.data.goal.currency;
|
||||
if goal_currency != "USD" {
|
||||
return Err(ApiError::Internal(eyre!(
|
||||
"goal amount is in {goal_currency}, must be USD"
|
||||
)));
|
||||
}
|
||||
|
||||
let campaign_info = CampaignInfo {
|
||||
total_donations_usd: response.data.total_amount_raised.value,
|
||||
target_usd: response.data.goal.value,
|
||||
num_donators: num_donators(&http, &access_token, campaign_id).await?,
|
||||
};
|
||||
|
||||
redis_connection
|
||||
.set_serialized_to_json(
|
||||
CAMPAIGN_INFO_CACHE_NAMESPACE,
|
||||
campaign_id,
|
||||
&campaign_info,
|
||||
Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS),
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("caching campaign info")?;
|
||||
|
||||
Ok(web::Json(campaign_info))
|
||||
}
|
||||
|
||||
async fn num_donators(
|
||||
http: &HttpClient,
|
||||
access_token: &str,
|
||||
campaign_id: &str,
|
||||
) -> Result<usize, ApiError> {
|
||||
let mut after = None;
|
||||
let mut donors = HashSet::new();
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"https://v5api.tiltify.com/api/public/team_campaigns/{campaign_id}/donations"
|
||||
);
|
||||
let mut request = http
|
||||
.get(url)
|
||||
.bearer_auth(access_token)
|
||||
.query(&[("limit", "100")]);
|
||||
|
||||
if let Some(after) = &after {
|
||||
request = request.query(&[("after", after)]);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.wrap_internal_err("fetching donations from Tiltify")?
|
||||
.error_for_status()
|
||||
.wrap_internal_err("fetching donations from Tiltify")?
|
||||
.json::<TiltifyDonationResponse>()
|
||||
.await
|
||||
.wrap_internal_err("parsing Tiltify donations response")?;
|
||||
|
||||
donors.extend(
|
||||
response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|donation| donation.donor_name)
|
||||
.filter(|donor_name| donor_name != "Anonymous"),
|
||||
);
|
||||
|
||||
match response.metadata.after {
|
||||
Some(next_after) => after = Some(next_after),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(donors.len())
|
||||
}
|
||||
|
||||
async fn amount_raised_usd(
|
||||
amount: &AmountRaised,
|
||||
payouts_queue: &PayoutsQueue,
|
||||
) -> Result<Decimal, ApiError> {
|
||||
let currency = amount.currency.to_uppercase();
|
||||
|
||||
if currency == "USD" {
|
||||
return Ok(amount.value);
|
||||
}
|
||||
|
||||
let forex: TremendousForexResponse = payouts_queue
|
||||
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch Tremendous forex data")?;
|
||||
|
||||
let usd_to_currency = forex
|
||||
.forex
|
||||
.get(¤cy)
|
||||
.copied()
|
||||
.wrap_internal_err_with(|| {
|
||||
eyre!("no Tremendous forex rate for '{currency}'")
|
||||
})?;
|
||||
|
||||
Ok(amount.value / usd_to_currency)
|
||||
}
|
||||
@@ -239,6 +239,7 @@ impl TempUser {
|
||||
created: Utc::now(),
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
campaign_pride_26: None,
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: false,
|
||||
}
|
||||
@@ -1573,6 +1574,7 @@ pub async fn create_account_with_password(
|
||||
created: Utc::now(),
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
campaign_pride_26: None,
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: new_account
|
||||
.sign_up_newsletter
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod admin;
|
||||
pub mod affiliate;
|
||||
pub mod billing;
|
||||
pub mod campaign;
|
||||
pub mod delphi;
|
||||
pub mod external_notifications;
|
||||
pub mod flows;
|
||||
@@ -85,6 +86,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(affiliate::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/campaign")
|
||||
.wrap(default_cors())
|
||||
.configure(campaign::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/search-management")
|
||||
.wrap(default_cors())
|
||||
|
||||
@@ -18,5 +18,6 @@ pub mod redis;
|
||||
pub mod routes;
|
||||
pub mod sentry;
|
||||
pub mod tags;
|
||||
pub mod tiltify;
|
||||
pub mod validate;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eyre::eyre;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
env::ENV,
|
||||
util::{error::Context, http::HttpClient},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TiltifyClient {
|
||||
http: HttpClient,
|
||||
token: Mutex<Option<TiltifyAccessToken>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TiltifyAccessToken {
|
||||
access_token: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TiltifyTokenResponse {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
impl TiltifyClient {
|
||||
pub fn new(http: HttpClient) -> Self {
|
||||
Self {
|
||||
http,
|
||||
token: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn access_token(&self) -> eyre::Result<String> {
|
||||
let mut token = self.token.lock().await;
|
||||
|
||||
if let Some(token) = token.as_ref()
|
||||
&& token.expires_at > Instant::now()
|
||||
{
|
||||
return Ok(token.access_token.clone());
|
||||
}
|
||||
|
||||
let response = self.fetch_access_token().await?;
|
||||
let expires_at = Instant::now()
|
||||
+ Duration::from_secs(response.expires_in)
|
||||
.saturating_sub(Duration::from_secs(60));
|
||||
let access_token = response.access_token;
|
||||
|
||||
*token = Some(TiltifyAccessToken {
|
||||
access_token: access_token.clone(),
|
||||
expires_at,
|
||||
});
|
||||
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
async fn fetch_access_token(&self) -> eyre::Result<TiltifyTokenResponse> {
|
||||
if ENV.TILTIFY_CLIENT_ID.is_empty()
|
||||
|| ENV.TILTIFY_CLIENT_SECRET.is_empty()
|
||||
{
|
||||
return Err(eyre!(
|
||||
"TILTIFY_CLIENT_ID and TILTIFY_CLIENT_SECRET must be set"
|
||||
));
|
||||
}
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post("https://v5api.tiltify.com/oauth/token")
|
||||
.json(&json!({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": &ENV.TILTIFY_CLIENT_ID,
|
||||
"client_secret": &ENV.TILTIFY_CLIENT_SECRET,
|
||||
"scope": "public",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.wrap_err("fetching OAuth token")?
|
||||
.error_for_status()
|
||||
.wrap_err("fetching OAuth token")?
|
||||
.json::<TiltifyTokenResponse>()
|
||||
.await
|
||||
.wrap_err("parsing OAuth token response")?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user