Files
modrinth/apps/labrinth/src/routes/internal/medal.rs
T
aecsocket 3fa6905006 refactor: labrinth ApiError and error reporting (#6981)
* refactor: labrinth `ApiError` and error reporting

* fix clippy

* fix ci
2026-08-07 15:47:54 +00:00

135 lines
3.7 KiB
Rust

use crate::database::PgPool;
use crate::util::error::Context as _;
use actix_web::{HttpResponse, post, web};
use ariadne::ids::UserId;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::database::models::users_redeemals::{
Offer, RedeemalLookupFields, Status, UserRedeemal,
};
use crate::queue::billing::try_process_user_redeemal;
use crate::routes::ApiError;
use crate::util::guards::medal_key_guard;
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/medal").service(verify).service(redeem));
}
#[derive(Deserialize)]
struct MedalQuery {
username: String,
}
#[derive(Serialize, utoipa::ToSchema)]
struct VerifyResponse {
user_id: UserId,
redeemed: bool,
}
/// Verify Medal credentials.
#[utoipa::path(
context_path = "/medal",
tag = "medal",
responses((status = OK, body = VerifyResponse))
)]
#[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
.wrap_internal_err("executing `RedeemalLookupFields::redeemal_status_by_username_and_offer`")?;
match maybe_fields {
None => Err(ApiError::NotFound(eyre::eyre!("resource not found"))),
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
user_id: fields.user_id.into(),
redeemed: fields.redeemal_status.is_some(),
})),
}
}
/// Redeem Medal credit.
#[utoipa::path(
context_path = "/medal",
tag = "medal",
responses((status = ACCEPTED), (status = CREATED))
)]
#[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
.wrap_internal_err("starting database transaction")?;
let maybe_fields =
RedeemalLookupFields::redeemal_status_by_username_and_offer(
&mut txn,
&username,
Offer::Medal,
)
.await
.wrap_internal_err("executing `RedeemalLookupFields::redeemal_status_by_username_and_offer`")?;
let user_id = match maybe_fields {
None => {
return Err(ApiError::NotFound(eyre::eyre!("resource not found")));
}
Some(fields) => {
if fields.redeemal_status.is_some() {
return Err(ApiError::Conflict(eyre::eyre!(
"User already redeemed this offer",
)));
}
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
.wrap_internal_err("inserting database records for `redeem`")?;
txn.commit()
.await
.wrap_internal_err("committing database transaction")?;
// 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())
}
}