You've already forked AstralRinth
forked from didirus/AstralRinth
Offers, redemption, preview subscriptions (#4121)
* Initial db migration/impl, guarded partner routes * Add guard to /redeem * Add `public` column to products prices, only expose public prices * Query cache * Add partner subscription type * 5 days subscription interval, metadata * Create server on redeem * Query cache * Fix race condition * Unprovision Medal subscriptions * Consider due expiring charge as unprovisionable * Query cache * Use a queue * Promote to full subscription, fmt + clippy * Patch expiring charge on promotion, comments * Additional comments * Add `tags` field to Archon /create request * Address review comments * Query cache * Final fixes to edit_subscription * Appease clippy * fmt
This commit is contained in:
committed by
GitHub
parent
c02b809601
commit
9497ba70a4
File diff suppressed because it is too large
Load Diff
109
apps/labrinth/src/routes/internal/medal.rs
Normal file
109
apps/labrinth/src/routes/internal/medal.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use actix_web::{HttpResponse, post, web};
|
||||
use ariadne::ids::UserId;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::database::models::users_redeemals::{
|
||||
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::billing::try_process_user_redeemal;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("medal").service(verify).service(redeem));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MedalQuery {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[post("verify", guard = "medal_key_guard")]
|
||||
pub async fn verify(
|
||||
pool: web::Data<PgPool>,
|
||||
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let maybe_fields =
|
||||
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||
&**pool,
|
||||
&username,
|
||||
Offer::Medal,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyResponse {
|
||||
user_id: UserId,
|
||||
redeemed: bool,
|
||||
}
|
||||
|
||||
match maybe_fields {
|
||||
None => Err(ApiError::NotFound),
|
||||
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
|
||||
user_id: fields.user_id.into(),
|
||||
redeemed: fields.redeemal_status.is_some(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("redeem", guard = "medal_key_guard")]
|
||||
pub async fn redeem(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Check the offer hasn't been redeemed yet, then insert into the table.
|
||||
// In a transaction to avoid double inserts.
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
let maybe_fields =
|
||||
RedeemalLookupFields::redeemal_status_by_username_and_offer(
|
||||
&mut *txn,
|
||||
&username,
|
||||
Offer::Medal,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_id = match maybe_fields {
|
||||
None => return Err(ApiError::NotFound),
|
||||
Some(fields) => {
|
||||
if fields.redeemal_status.is_some() {
|
||||
return Err(ApiError::Conflict(
|
||||
"User already redeemed this offer".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
fields.user_id
|
||||
}
|
||||
};
|
||||
|
||||
// Link user to offer redeemal.
|
||||
let mut redeemal = UserRedeemal {
|
||||
id: 0,
|
||||
user_id,
|
||||
offer: Offer::Medal,
|
||||
redeemed: Utc::now(),
|
||||
status: Status::Pending,
|
||||
last_attempt: None,
|
||||
n_attempts: 0,
|
||||
};
|
||||
|
||||
redeemal.insert(&mut *txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
// Immediately try to process the redeemal
|
||||
if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||
{
|
||||
warn!(%error, "Medal redeemal processing failed");
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
} else {
|
||||
Ok(HttpResponse::Created().finish())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub(crate) mod admin;
|
||||
pub mod billing;
|
||||
pub mod flows;
|
||||
pub mod gdpr;
|
||||
pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
@@ -24,6 +25,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(moderation::config)
|
||||
.configure(billing::config)
|
||||
.configure(gdpr::config)
|
||||
.configure(statuses::config),
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ pub enum ApiError {
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Resource not found")]
|
||||
NotFound,
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error(
|
||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||
)]
|
||||
@@ -172,6 +174,7 @@ impl ApiError {
|
||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||
ApiError::Reroute(..) => "reroute_error",
|
||||
ApiError::NotFound => "not_found",
|
||||
ApiError::Conflict(..) => "conflict",
|
||||
ApiError::Zip(..) => "zip_error",
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
@@ -208,6 +211,7 @@ impl actix_web::ResponseError for ApiError {
|
||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
|
||||
Reference in New Issue
Block a user