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
34
apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json
generated
Normal file
34
apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json
generated
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "unitary",
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8"
|
||||||
|
}
|
||||||
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal file
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users_redeemals\n SET status = $1\n WHERE\n status = $2\n AND NOW() - last_attempt > INTERVAL '5 minutes'\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341"
|
||||||
|
}
|
||||||
27
apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json
generated
Normal file
27
apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO users_redeemals\n (user_id, offer, redeemed, status, last_attempt, n_attempts)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
"hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
|
||||||
}
|
}
|
||||||
18
apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json
generated
Normal file
18
apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json
generated
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users_redeemals\n SET\n status = $3,\n last_attempt = $4,\n n_attempts = $5\n WHERE id = $1 AND status = $2\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4",
|
||||||
|
"Text",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf"
|
||||||
|
}
|
||||||
41
apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json
generated
Normal file
41
apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json
generated
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE product_id = ANY($1::bigint[]) AND public = $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "product_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "prices",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "currency_code",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8Array",
|
||||||
|
"Bool"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1"
|
||||||
|
}
|
||||||
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal file
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "offer",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "redeemed",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "last_attempt",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "n_attempts",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286"
|
||||||
|
}
|
||||||
19
apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json
generated
Normal file
19
apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4,\n last_attempt = $5,\n n_attempts = $6\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4",
|
||||||
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
|
"Timestamptz",
|
||||||
|
"Timestamptz",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380"
|
||||||
|
}
|
||||||
23
apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json
generated
Normal file
23
apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json
generated
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n EXISTS (\n SELECT\n 1\n FROM\n users_redeemals\n WHERE\n user_id = $1\n AND offer = $2\n ) AS \"exists!\"\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "exists!",
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a"
|
||||||
|
}
|
||||||
29
apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json
generated
Normal file
29
apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json
generated
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT\n users.id,\n users_redeemals.status AS \"status: Option<String>\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "status: Option<String>",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
|
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE users_redeemals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
offer VARCHAR NOT NULL,
|
||||||
|
redeemed TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
status VARCHAR NOT NULL,
|
||||||
|
last_attempt TIMESTAMP WITH TIME ZONE,
|
||||||
|
n_attempts INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
products_prices
|
||||||
|
ADD COLUMN
|
||||||
|
public BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
|||||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||||
let user_subscription_id = user_subscription_id.0;
|
let user_subscription_id = user_subscription_id.0;
|
||||||
let res = select_charges_with_predicate!(
|
let res = select_charges_with_predicate!(
|
||||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||||
user_subscription_id
|
user_subscription_id
|
||||||
)
|
)
|
||||||
.fetch_optional(exec)
|
.fetch_optional(exec)
|
||||||
@@ -240,6 +240,7 @@ impl DBCharge {
|
|||||||
charge_type = $1 AND
|
charge_type = $1 AND
|
||||||
(
|
(
|
||||||
(status = 'cancelled' AND due < NOW()) OR
|
(status = 'cancelled' AND due < NOW()) OR
|
||||||
|
(status = 'expiring' AND due < NOW()) OR
|
||||||
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
|
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod team_item;
|
|||||||
pub mod thread_item;
|
pub mod thread_item;
|
||||||
pub mod user_item;
|
pub mod user_item;
|
||||||
pub mod user_subscription_item;
|
pub mod user_subscription_item;
|
||||||
|
pub mod users_redeemals;
|
||||||
pub mod version_item;
|
pub mod version_item;
|
||||||
|
|
||||||
pub use collection_item::DBCollection;
|
pub use collection_item::DBCollection;
|
||||||
|
|||||||
@@ -57,6 +57,26 @@ impl DBProduct {
|
|||||||
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_type<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
r#type: &str,
|
||||||
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a>,
|
||||||
|
{
|
||||||
|
let maybe_row = select_products_with_predicate!(
|
||||||
|
"WHERE metadata ->> 'type' = $1",
|
||||||
|
r#type
|
||||||
|
)
|
||||||
|
.fetch_all(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
maybe_row
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.try_into().map_err(Into::into))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_many(
|
pub async fn get_many(
|
||||||
ids: &[DBProductId],
|
ids: &[DBProductId],
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
@@ -100,10 +120,11 @@ pub struct QueryProductWithPrices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl QueryProductWithPrices {
|
impl QueryProductWithPrices {
|
||||||
pub async fn list<'a, E>(
|
/// Lists products with at least one public price.
|
||||||
|
pub async fn list_purchaseable<'a, E>(
|
||||||
exec: E,
|
exec: E,
|
||||||
redis: &RedisPool,
|
redis: &RedisPool,
|
||||||
) -> Result<Vec<QueryProductWithPrices>, DatabaseError>
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
where
|
where
|
||||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||||
{
|
{
|
||||||
@@ -118,7 +139,51 @@ impl QueryProductWithPrices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let all_products = product_item::DBProduct::get_all(exec).await?;
|
let all_products = product_item::DBProduct::get_all(exec).await?;
|
||||||
let prices = product_item::DBProductPrice::get_all_products_prices(
|
let prices =
|
||||||
|
product_item::DBProductPrice::get_all_public_products_prices(
|
||||||
|
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||||
|
exec,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let products = all_products
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|x| {
|
||||||
|
Some(QueryProductWithPrices {
|
||||||
|
id: x.id,
|
||||||
|
metadata: x.metadata,
|
||||||
|
prices: prices
|
||||||
|
.remove(&x.id)
|
||||||
|
.map(|x| x.1)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| DBProductPrice {
|
||||||
|
id: x.id,
|
||||||
|
product_id: x.product_id,
|
||||||
|
prices: x.prices,
|
||||||
|
currency_code: x.currency_code,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
unitary: x.unitary,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
redis
|
||||||
|
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(products)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_product_type<'a, E>(
|
||||||
|
exec: E,
|
||||||
|
r#type: &str,
|
||||||
|
) -> Result<Vec<Self>, DatabaseError>
|
||||||
|
where
|
||||||
|
E: sqlx::PgExecutor<'a> + Copy,
|
||||||
|
{
|
||||||
|
let all_products = DBProduct::get_by_type(exec, r#type).await?;
|
||||||
|
let prices = DBProductPrice::get_all_products_prices(
|
||||||
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
|
||||||
exec,
|
exec,
|
||||||
)
|
)
|
||||||
@@ -126,29 +191,26 @@ impl QueryProductWithPrices {
|
|||||||
|
|
||||||
let products = all_products
|
let products = all_products
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| QueryProductWithPrices {
|
.filter_map(|x| {
|
||||||
id: x.id,
|
Some(QueryProductWithPrices {
|
||||||
metadata: x.metadata,
|
id: x.id,
|
||||||
prices: prices
|
metadata: x.metadata,
|
||||||
.remove(&x.id)
|
prices: prices
|
||||||
.map(|x| x.1)
|
.remove(&x.id)
|
||||||
.unwrap_or_default()
|
.map(|x| x.1)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| DBProductPrice {
|
.map(|x| DBProductPrice {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
product_id: x.product_id,
|
product_id: x.product_id,
|
||||||
prices: x.prices,
|
prices: x.prices,
|
||||||
currency_code: x.currency_code,
|
currency_code: x.currency_code,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
unitary: x.unitary,
|
unitary: x.unitary,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
redis
|
|
||||||
.set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(products)
|
Ok(products)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +231,11 @@ struct ProductPriceQueryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! select_prices_with_predicate {
|
macro_rules! select_prices_with_predicate {
|
||||||
($predicate:tt, $param:ident) => {
|
($predicate:tt, $param1:ident) => {
|
||||||
|
select_prices_with_predicate!($predicate, $param1, )
|
||||||
|
};
|
||||||
|
|
||||||
|
($predicate:tt, $($param:ident,)+) => {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
ProductPriceQueryResult,
|
ProductPriceQueryResult,
|
||||||
r#"
|
r#"
|
||||||
@@ -177,7 +243,7 @@ macro_rules! select_prices_with_predicate {
|
|||||||
FROM products_prices
|
FROM products_prices
|
||||||
"#
|
"#
|
||||||
+ $predicate,
|
+ $predicate,
|
||||||
$param
|
$($param),+
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,33 +297,81 @@ impl DBProductPrice {
|
|||||||
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_public_product_prices(
|
||||||
|
product_id: DBProductId,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<Vec<DBProductPrice>, DatabaseError> {
|
||||||
|
let res =
|
||||||
|
Self::get_all_public_products_prices(&[product_id], exec).await?;
|
||||||
|
|
||||||
|
Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all public prices for the given products. If a product has no public price,
|
||||||
|
/// it won't be included in the resulting map.
|
||||||
|
pub async fn get_all_public_products_prices(
|
||||||
|
product_ids: &[DBProductId],
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
|
Self::get_all_products_prices_with_visibility(
|
||||||
|
product_ids,
|
||||||
|
Some(true),
|
||||||
|
exec,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_all_products_prices(
|
pub async fn get_all_products_prices(
|
||||||
product_ids: &[DBProductId],
|
product_ids: &[DBProductId],
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
|
Self::get_all_products_prices_with_visibility(product_ids, None, exec)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_products_prices_with_visibility(
|
||||||
|
product_ids: &[DBProductId],
|
||||||
|
public_filter: Option<bool>,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
|
||||||
let ids = product_ids.iter().map(|id| id.0).collect_vec();
|
let ids = product_ids.iter().map(|id| id.0).collect_vec();
|
||||||
let ids_ref: &[i64] = &ids;
|
let ids_ref: &[i64] = &ids;
|
||||||
|
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
let prices = select_prices_with_predicate!(
|
|
||||||
"WHERE product_id = ANY($1::bigint[])",
|
|
||||||
ids_ref
|
|
||||||
)
|
|
||||||
.fetch(exec)
|
|
||||||
.try_fold(
|
|
||||||
DashMap::new(),
|
|
||||||
|acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
|
||||||
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
|
||||||
DBProductPrice,
|
|
||||||
>>::try_into(x)
|
|
||||||
{
|
|
||||||
acc.entry(item.product_id).or_default().push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
async move { Ok(acc) }
|
let predicate = |acc: DashMap<DBProductId, Vec<DBProductPrice>>, x| {
|
||||||
},
|
if let Ok(item) = <ProductPriceQueryResult as TryInto<
|
||||||
)
|
DBProductPrice,
|
||||||
.await?;
|
>>::try_into(x)
|
||||||
|
{
|
||||||
|
acc.entry(item.product_id).or_default().push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async move { Ok(acc) }
|
||||||
|
};
|
||||||
|
|
||||||
|
let prices = match public_filter {
|
||||||
|
None => {
|
||||||
|
select_prices_with_predicate!(
|
||||||
|
"WHERE product_id = ANY($1::bigint[])",
|
||||||
|
ids_ref,
|
||||||
|
)
|
||||||
|
.fetch(exec)
|
||||||
|
.try_fold(DashMap::new(), predicate)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(public) => {
|
||||||
|
select_prices_with_predicate!(
|
||||||
|
"WHERE product_id = ANY($1::bigint[]) AND public = $2",
|
||||||
|
ids_ref,
|
||||||
|
public,
|
||||||
|
)
|
||||||
|
.fetch(exec)
|
||||||
|
.try_fold(DashMap::new(), predicate)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(prices)
|
Ok(prices)
|
||||||
}
|
}
|
||||||
|
|||||||
299
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
299
apps/labrinth/src/database/models/users_redeemals.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
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),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,13 @@ pub enum ProductMetadata {
|
|||||||
swap: u32,
|
swap: u32,
|
||||||
storage: u32,
|
storage: u32,
|
||||||
},
|
},
|
||||||
|
Medal {
|
||||||
|
cpu: u32,
|
||||||
|
ram: u32,
|
||||||
|
swap: u32,
|
||||||
|
storage: u32,
|
||||||
|
region: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -48,6 +55,7 @@ pub enum Price {
|
|||||||
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum PriceDuration {
|
pub enum PriceDuration {
|
||||||
|
FiveDays,
|
||||||
Monthly,
|
Monthly,
|
||||||
Quarterly,
|
Quarterly,
|
||||||
Yearly,
|
Yearly,
|
||||||
@@ -56,6 +64,7 @@ pub enum PriceDuration {
|
|||||||
impl PriceDuration {
|
impl PriceDuration {
|
||||||
pub fn duration(&self) -> chrono::Duration {
|
pub fn duration(&self) -> chrono::Duration {
|
||||||
match self {
|
match self {
|
||||||
|
PriceDuration::FiveDays => chrono::Duration::days(5),
|
||||||
PriceDuration::Monthly => chrono::Duration::days(30),
|
PriceDuration::Monthly => chrono::Duration::days(30),
|
||||||
PriceDuration::Quarterly => chrono::Duration::days(90),
|
PriceDuration::Quarterly => chrono::Duration::days(90),
|
||||||
PriceDuration::Yearly => chrono::Duration::days(365),
|
PriceDuration::Yearly => chrono::Duration::days(365),
|
||||||
@@ -64,6 +73,7 @@ impl PriceDuration {
|
|||||||
|
|
||||||
pub fn from_string(string: &str) -> PriceDuration {
|
pub fn from_string(string: &str) -> PriceDuration {
|
||||||
match string {
|
match string {
|
||||||
|
"five-days" => PriceDuration::FiveDays,
|
||||||
"monthly" => PriceDuration::Monthly,
|
"monthly" => PriceDuration::Monthly,
|
||||||
"quarterly" => PriceDuration::Quarterly,
|
"quarterly" => PriceDuration::Quarterly,
|
||||||
"yearly" => PriceDuration::Yearly,
|
"yearly" => PriceDuration::Yearly,
|
||||||
@@ -76,6 +86,7 @@ impl PriceDuration {
|
|||||||
PriceDuration::Monthly => "monthly",
|
PriceDuration::Monthly => "monthly",
|
||||||
PriceDuration::Quarterly => "quarterly",
|
PriceDuration::Quarterly => "quarterly",
|
||||||
PriceDuration::Yearly => "yearly",
|
PriceDuration::Yearly => "yearly",
|
||||||
|
PriceDuration::FiveDays => "five-days",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +95,7 @@ impl PriceDuration {
|
|||||||
PriceDuration::Monthly,
|
PriceDuration::Monthly,
|
||||||
PriceDuration::Quarterly,
|
PriceDuration::Quarterly,
|
||||||
PriceDuration::Yearly,
|
PriceDuration::Yearly,
|
||||||
|
PriceDuration::FiveDays,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
}
|
}
|
||||||
@@ -146,6 +158,7 @@ impl SubscriptionStatus {
|
|||||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||||
pub enum SubscriptionMetadata {
|
pub enum SubscriptionMetadata {
|
||||||
Pyro { id: String, region: Option<String> },
|
Pyro { id: String, region: Option<String> },
|
||||||
|
Medal { id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -201,12 +214,16 @@ impl ChargeType {
|
|||||||
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone, Debug)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum ChargeStatus {
|
pub enum ChargeStatus {
|
||||||
// Open charges are for the next billing interval
|
/// Open charges are for the next billing interval
|
||||||
Open,
|
Open,
|
||||||
Processing,
|
Processing,
|
||||||
Succeeded,
|
Succeeded,
|
||||||
Failed,
|
Failed,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
/// Expiring charges are charges that aren't expected to be processed
|
||||||
|
/// but can be promoted to a full charge, like for trials/freebies. When
|
||||||
|
/// due, the underlying subscription is unprovisioned.
|
||||||
|
Expiring,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChargeStatus {
|
impl ChargeStatus {
|
||||||
@@ -217,6 +234,7 @@ impl ChargeStatus {
|
|||||||
"failed" => ChargeStatus::Failed,
|
"failed" => ChargeStatus::Failed,
|
||||||
"open" => ChargeStatus::Open,
|
"open" => ChargeStatus::Open,
|
||||||
"cancelled" => ChargeStatus::Cancelled,
|
"cancelled" => ChargeStatus::Cancelled,
|
||||||
|
"expiring" => ChargeStatus::Expiring,
|
||||||
_ => ChargeStatus::Failed,
|
_ => ChargeStatus::Failed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +246,7 @@ impl ChargeStatus {
|
|||||||
ChargeStatus::Failed => "failed",
|
ChargeStatus::Failed => "failed",
|
||||||
ChargeStatus::Open => "open",
|
ChargeStatus::Open => "open",
|
||||||
ChargeStatus::Cancelled => "cancelled",
|
ChargeStatus::Cancelled => "cancelled",
|
||||||
|
ChargeStatus::Expiring => "expiring",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,12 +254,14 @@ impl ChargeStatus {
|
|||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PaymentPlatform {
|
pub enum PaymentPlatform {
|
||||||
Stripe,
|
Stripe,
|
||||||
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaymentPlatform {
|
impl PaymentPlatform {
|
||||||
pub fn from_string(string: &str) -> PaymentPlatform {
|
pub fn from_string(string: &str) -> PaymentPlatform {
|
||||||
match string {
|
match string {
|
||||||
"stripe" => PaymentPlatform::Stripe,
|
"stripe" => PaymentPlatform::Stripe,
|
||||||
|
"none" => PaymentPlatform::None,
|
||||||
_ => PaymentPlatform::Stripe,
|
_ => PaymentPlatform::Stripe,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +269,7 @@ impl PaymentPlatform {
|
|||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
PaymentPlatform::Stripe => "stripe",
|
PaymentPlatform::Stripe => "stripe",
|
||||||
|
PaymentPlatform::None => "none",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 billing;
|
||||||
pub mod flows;
|
pub mod flows;
|
||||||
pub mod gdpr;
|
pub mod gdpr;
|
||||||
|
pub mod medal;
|
||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod pats;
|
pub mod pats;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -24,6 +25,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.configure(moderation::config)
|
.configure(moderation::config)
|
||||||
.configure(billing::config)
|
.configure(billing::config)
|
||||||
.configure(gdpr::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),
|
Io(#[from] std::io::Error),
|
||||||
#[error("Resource not found")]
|
#[error("Resource not found")]
|
||||||
NotFound,
|
NotFound,
|
||||||
|
#[error("Conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
#[error(
|
#[error(
|
||||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||||
)]
|
)]
|
||||||
@@ -172,6 +174,7 @@ impl ApiError {
|
|||||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||||
ApiError::Reroute(..) => "reroute_error",
|
ApiError::Reroute(..) => "reroute_error",
|
||||||
ApiError::NotFound => "not_found",
|
ApiError::NotFound => "not_found",
|
||||||
|
ApiError::Conflict(..) => "conflict",
|
||||||
ApiError::Zip(..) => "zip_error",
|
ApiError::Zip(..) => "zip_error",
|
||||||
ApiError::Io(..) => "io_error",
|
ApiError::Io(..) => "io_error",
|
||||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||||
@@ -208,6 +211,7 @@ impl actix_web::ResponseError for ApiError {
|
|||||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||||
|
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
|||||||
75
apps/labrinth/src/util/archon.rs
Normal file
75
apps/labrinth/src/util/archon.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use reqwest::header::HeaderName;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::routes::ApiError;
|
||||||
|
|
||||||
|
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Empty {}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Specs {
|
||||||
|
pub memory_mb: u32,
|
||||||
|
pub cpu: u32,
|
||||||
|
pub swap_mb: u32,
|
||||||
|
pub storage_mb: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateServerRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub specs: Specs,
|
||||||
|
// Must be included because archon doesn't accept null values, only
|
||||||
|
// an empty struct, as a source.
|
||||||
|
pub source: Empty,
|
||||||
|
pub region: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ArchonClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
pyro_api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchonClient {
|
||||||
|
/// Builds an Archon client from environment variables. Returns `None` if the
|
||||||
|
/// required environment variables are not set.
|
||||||
|
pub fn from_env() -> Result<Self, ApiError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let base_url =
|
||||||
|
dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
base_url,
|
||||||
|
pyro_api_key: dotenvy::var("PYRO_API_KEY")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_server(
|
||||||
|
&self,
|
||||||
|
request: &CreateServerRequest,
|
||||||
|
) -> Result<Uuid, reqwest::Error> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct CreateServerResponse {
|
||||||
|
uuid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/modrinth/v0/servers/create", self.base_url))
|
||||||
|
.header(X_MASTER_KEY, &self.pyro_api_key)
|
||||||
|
.json(request)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
Ok(response.json::<CreateServerResponse>().await?.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use actix_web::guard::GuardContext;
|
use actix_web::guard::GuardContext;
|
||||||
|
|
||||||
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
||||||
|
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
|
||||||
|
|
||||||
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||||
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect(
|
||||||
"No admin key provided, this should have been caught by check_env_vars",
|
"No admin key provided, this should have been caught by check_env_vars",
|
||||||
@@ -10,3 +12,16 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
|||||||
.get(ADMIN_KEY_HEADER)
|
.get(ADMIN_KEY_HEADER)
|
||||||
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn medal_key_guard(ctx: &GuardContext) -> bool {
|
||||||
|
let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok();
|
||||||
|
|
||||||
|
match maybe_medal_key {
|
||||||
|
None => false,
|
||||||
|
Some(medal_key) => ctx
|
||||||
|
.head()
|
||||||
|
.headers()
|
||||||
|
.get(MEDAL_KEY_HEADER)
|
||||||
|
.is_some_and(|it| it.as_bytes() == medal_key.as_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod actix;
|
pub mod actix;
|
||||||
|
pub mod archon;
|
||||||
pub mod bitflag;
|
pub mod bitflag;
|
||||||
pub mod captcha;
|
pub mod captcha;
|
||||||
pub mod cors;
|
pub mod cors;
|
||||||
|
|||||||
Reference in New Issue
Block a user