forked from didirus/AstralRinth
* 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
300 lines
7.5 KiB
Rust
300 lines
7.5 KiB
Rust
use crate::database::models::DBUserId;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::{query, query_scalar};
|
|
use std::fmt;
|
|
|
|
#[derive(
|
|
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Offer {
|
|
#[default]
|
|
Medal,
|
|
}
|
|
|
|
impl Offer {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Offer::Medal => "medal",
|
|
}
|
|
}
|
|
|
|
pub fn from_str_or_default(s: &str) -> Self {
|
|
match s {
|
|
"medal" => Offer::Medal,
|
|
_ => Offer::Medal,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Offer {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Status {
|
|
#[default]
|
|
Pending,
|
|
Processing,
|
|
Processed,
|
|
}
|
|
|
|
impl Status {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Status::Pending => "pending",
|
|
Status::Processing => "processing",
|
|
Status::Processed => "processed",
|
|
}
|
|
}
|
|
|
|
pub fn from_str_or_default(s: &str) -> Self {
|
|
match s {
|
|
"pending" => Status::Pending,
|
|
"processing" => Status::Processing,
|
|
"processed" => Status::Processed,
|
|
_ => Status::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Status {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.as_str())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct UserRedeemal {
|
|
pub id: i32,
|
|
pub user_id: DBUserId,
|
|
pub offer: Offer,
|
|
pub redeemed: DateTime<Utc>,
|
|
pub last_attempt: Option<DateTime<Utc>>,
|
|
pub n_attempts: i32,
|
|
pub status: Status,
|
|
}
|
|
|
|
impl UserRedeemal {
|
|
pub async fn get_pending<'a, E>(
|
|
exec: E,
|
|
limit: i64,
|
|
) -> sqlx::Result<Vec<UserRedeemal>>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
let redeemals = query!(
|
|
r#"SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2"#,
|
|
Status::Pending.as_str(),
|
|
limit
|
|
)
|
|
.fetch_all(exec)
|
|
.await?
|
|
.into_iter()
|
|
.map(|row| UserRedeemal {
|
|
id: row.id,
|
|
user_id: DBUserId(row.user_id),
|
|
offer: Offer::from_str_or_default(&row.offer),
|
|
redeemed: row.redeemed,
|
|
last_attempt: row.last_attempt,
|
|
n_attempts: row.n_attempts,
|
|
status: Status::from_str_or_default(&row.status),
|
|
})
|
|
.collect();
|
|
|
|
Ok(redeemals)
|
|
}
|
|
|
|
pub async fn update_stuck_5_minutes<'a, E>(exec: E) -> sqlx::Result<()>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
query!(
|
|
r#"
|
|
UPDATE users_redeemals
|
|
SET status = $1
|
|
WHERE
|
|
status = $2
|
|
AND NOW() - last_attempt > INTERVAL '5 minutes'
|
|
"#,
|
|
Status::Pending.as_str(),
|
|
Status::Processing.as_str(),
|
|
)
|
|
.execute(exec)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn exists_by_user_and_offer<'a, E>(
|
|
exec: E,
|
|
user_id: DBUserId,
|
|
offer: Offer,
|
|
) -> sqlx::Result<bool>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
query_scalar!(
|
|
r#"SELECT
|
|
EXISTS (
|
|
SELECT
|
|
1
|
|
FROM
|
|
users_redeemals
|
|
WHERE
|
|
user_id = $1
|
|
AND offer = $2
|
|
) AS "exists!"
|
|
"#,
|
|
user_id.0,
|
|
offer.as_str(),
|
|
)
|
|
.fetch_one(exec)
|
|
.await
|
|
}
|
|
|
|
pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
let query = query_scalar!(
|
|
r#"INSERT INTO users_redeemals
|
|
(user_id, offer, redeemed, status, last_attempt, n_attempts)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING id
|
|
"#,
|
|
self.user_id.0,
|
|
self.offer.as_str(),
|
|
self.redeemed,
|
|
self.status.as_str(),
|
|
self.last_attempt,
|
|
self.n_attempts,
|
|
);
|
|
|
|
let id = query.fetch_one(exec).await?;
|
|
|
|
self.id = id;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Updates `status`, `last_attempt`, and `n_attempts` only if `status` is currently pending.
|
|
/// Returns `true` if the status was updated, `false` otherwise.
|
|
pub async fn update_status_if_pending<'a, E>(
|
|
&self,
|
|
exec: E,
|
|
) -> sqlx::Result<bool>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
let query = query!(
|
|
r#"UPDATE users_redeemals
|
|
SET
|
|
status = $3,
|
|
last_attempt = $4,
|
|
n_attempts = $5
|
|
WHERE id = $1 AND status = $2
|
|
"#,
|
|
self.id,
|
|
Status::Pending.as_str(),
|
|
self.status.as_str(),
|
|
self.last_attempt,
|
|
self.n_attempts,
|
|
);
|
|
|
|
let query_result = query.execute(exec).await?;
|
|
|
|
Ok(query_result.rows_affected() > 0)
|
|
}
|
|
|
|
pub async fn update<'a, E>(&self, exec: E) -> sqlx::Result<()>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
let query = query!(
|
|
r#"UPDATE users_redeemals
|
|
SET
|
|
offer = $2,
|
|
status = $3,
|
|
redeemed = $4,
|
|
last_attempt = $5,
|
|
n_attempts = $6
|
|
WHERE id = $1
|
|
"#,
|
|
self.id,
|
|
self.offer.as_str(),
|
|
self.status.as_str(),
|
|
self.redeemed,
|
|
self.last_attempt,
|
|
self.n_attempts,
|
|
);
|
|
|
|
query.execute(exec).await?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RedeemalLookupFields {
|
|
pub user_id: DBUserId,
|
|
pub redeemal_status: Option<Status>,
|
|
}
|
|
|
|
impl RedeemalLookupFields {
|
|
/// Returns the redeemal status of a user for an offer, while looking up the user
|
|
/// itself. **This expects a single redeemal per user/offer pair**.
|
|
///
|
|
/// If the returned value is `Ok(None)`, the user doesn't exist.
|
|
///
|
|
/// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`,
|
|
/// the user exists and has not redeemed the offer.
|
|
pub async fn redeemal_status_by_username_and_offer<'a, E>(
|
|
exec: E,
|
|
user_username: &str,
|
|
offer: Offer,
|
|
) -> sqlx::Result<Option<RedeemalLookupFields>>
|
|
where
|
|
E: sqlx::PgExecutor<'a>,
|
|
{
|
|
let maybe_row = query!(
|
|
r#"
|
|
SELECT
|
|
users.id,
|
|
users_redeemals.status AS "status: Option<String>"
|
|
FROM
|
|
users
|
|
LEFT JOIN
|
|
users_redeemals ON users_redeemals.user_id = users.id
|
|
AND users_redeemals.offer = $2
|
|
WHERE
|
|
users.username = $1
|
|
ORDER BY
|
|
users_redeemals.redeemed DESC
|
|
LIMIT 1
|
|
"#,
|
|
user_username,
|
|
offer.as_str(),
|
|
)
|
|
.fetch_optional(exec)
|
|
.await?;
|
|
|
|
// If no row was returned, the user doesn't exist.
|
|
// If a row NULL status was returned, the user exists but has no redeemed the offer.
|
|
|
|
Ok(maybe_row.map(|row| RedeemalLookupFields {
|
|
user_id: DBUserId(row.id),
|
|
redeemal_status: row
|
|
.status
|
|
.as_deref()
|
|
.map(Status::from_str_or_default),
|
|
}))
|
|
}
|
|
}
|