Add public column to products prices, only expose public prices

This commit is contained in:
fetch
2025-08-04 20:31:49 -04:00
parent b65a16adff
commit da0fed3e21
6 changed files with 98 additions and 41 deletions

View File

@@ -0,0 +1,6 @@
-- Add migration script here
ALTER TABLE
products_prices
ADD COLUMN
public BOOLEAN NOT NULL DEFAULT true;

View File

@@ -100,7 +100,8 @@ 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<QueryProductWithPrices>, DatabaseError>
@@ -118,30 +119,32 @@ 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 =
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(), product_item::DBProductPrice::get_all_public_products_prices(
exec, &all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
) exec,
.await?; )
.await?;
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<_>>();
@@ -222,16 +225,19 @@ impl DBProductPrice {
.collect::<Result<Vec<_>, serde_json::Error>>()?) .collect::<Result<Vec<_>, serde_json::Error>>()?)
} }
pub async fn get_all_product_prices( pub async fn get_all_public_product_prices(
product_id: DBProductId, product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProductPrice>, DatabaseError> { ) -> Result<Vec<DBProductPrice>, DatabaseError> {
let res = Self::get_all_products_prices(&[product_id], exec).await?; let res =
Self::get_all_public_products_prices(&[product_id], exec).await?;
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_products_prices( /// 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], product_ids: &[DBProductId],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> { ) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
@@ -240,7 +246,8 @@ impl DBProductPrice {
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
let prices = select_prices_with_predicate!( let prices = select_prices_with_predicate!(
"WHERE product_id = ANY($1::bigint[])", "WHERE product_id = ANY($1::bigint[])
AND public = true",
ids_ref ids_ref
) )
.fetch(exec) .fetch(exec)

View File

@@ -145,7 +145,7 @@ impl RedeemalLookupFields {
/// ///
/// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`, /// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`,
/// the user exists and has not redeemed the offer. /// the user exists and has not redeemed the offer.
pub async fn redeemal_status_by_user_username_and_offer<'a, E>( pub async fn redeemal_status_by_username_and_offer<'a, E>(
exec: E, exec: E,
user_username: &str, user_username: &str,
offer: Offer, offer: Offer,
@@ -183,7 +183,7 @@ impl RedeemalLookupFields {
redeemal_status: row redeemal_status: row
.status .status
.as_deref() .as_deref()
.map(|s| Status::from_str_or_default(s)), .map(Status::from_str_or_default),
})) }))
} }
} }

View File

@@ -59,8 +59,10 @@ pub async fn products(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let products = let products = product_item::QueryProductWithPrices::list_purchaseable(
product_item::QueryProductWithPrices::list(&**pool, &redis).await?; &**pool, &redis,
)
.await?;
let products = products let products = products
.into_iter() .into_iter()
@@ -408,7 +410,7 @@ pub async fn edit_subscription(
let intent = if let Some(product_id) = &edit_subscription.product { let intent = if let Some(product_id) = &edit_subscription.product {
let product_price = let product_price =
product_item::DBProductPrice::get_all_product_prices( product_item::DBProductPrice::get_all_public_product_prices(
(*product_id).into(), (*product_id).into(),
&mut *transaction, &mut *transaction,
) )
@@ -1187,7 +1189,7 @@ pub async fn initiate_payment(
})?; })?;
let mut product_prices = let mut product_prices =
product_item::DBProductPrice::get_all_product_prices( product_item::DBProductPrice::get_all_public_product_prices(
product.id, &**pool, product.id, &**pool,
) )
.await?; .await?;

View File

@@ -1,9 +1,12 @@
use actix_web::{HttpRequest, HttpResponse, post, web}; use actix_web::{HttpResponse, post, web};
use ariadne::ids::UserId; use ariadne::ids::UserId;
use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use crate::database::models::users_redeemals::{Offer, RedeemalLookupFields}; use crate::database::models::users_redeemals::{
Offer, RedeemalLookupFields, Status, UserRedeemal,
};
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::guards::medal_key_guard; use crate::util::guards::medal_key_guard;
@@ -12,18 +15,17 @@ pub fn config(cfg: &mut web::ServiceConfig) {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct MedalVerifyQuery { struct MedalQuery {
username: String, username: String,
} }
#[post("verify", guard = "medal_key_guard")] #[post("verify", guard = "medal_key_guard")]
pub async fn verify( pub async fn verify(
_req: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
web::Query(MedalVerifyQuery { username }): web::Query<MedalVerifyQuery>, web::Query(MedalQuery { username }): web::Query<MedalQuery>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let maybe_fields = let maybe_fields =
RedeemalLookupFields::redeemal_status_by_user_username_and_offer( RedeemalLookupFields::redeemal_status_by_username_and_offer(
&**pool, &**pool,
&username, &username,
Offer::Medal, Offer::Medal,
@@ -47,8 +49,44 @@ pub async fn verify(
#[post("redeem", guard = "medal_key_guard")] #[post("redeem", guard = "medal_key_guard")]
pub async fn redeem( pub async fn redeem(
_req: HttpRequest, pool: web::Data<PgPool>,
_pool: web::Data<PgPool>, web::Query(MedalQuery { username }): web::Query<MedalQuery>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
Ok(HttpResponse::NotImplemented().finish()) // Check the offer hasn't been redeemed yet, then insert into the table.
let mut txn = pool.begin().await?;
let maybe_fields =
RedeemalLookupFields::redeemal_status_by_username_and_offer(
&mut *txn,
&username,
Offer::Medal,
)
.await?;
let _redeemal = 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(),
));
}
let mut redeemal = UserRedeemal {
id: 0,
user_id: fields.user_id,
offer: Offer::Medal,
redeemed: Utc::now(),
status: Status::Pending,
};
redeemal.insert(&mut *txn).await?;
redeemal
}
};
txn.commit().await?;
Ok(HttpResponse::Ok().finish())
} }

View File

@@ -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,