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
No known key found for this signature in database
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 {
pub async fn list<'a, E>(
/// Lists products with at least one public price.
pub async fn list_purchaseable<'a, E>(
exec: E,
redis: &RedisPool,
) -> Result<Vec<QueryProductWithPrices>, DatabaseError>
@ -118,30 +119,32 @@ impl QueryProductWithPrices {
}
let all_products = product_item::DBProduct::get_all(exec).await?;
let prices = product_item::DBProductPrice::get_all_products_prices(
&all_products.iter().map(|x| x.id).collect::<Vec<_>>(),
exec,
)
.await?;
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()
.map(|x| QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
prices: prices
.remove(&x.id)
.map(|x| x.1)
.unwrap_or_default()
.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,
.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<_>>();
@ -222,16 +225,19 @@ impl DBProductPrice {
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_all_product_prices(
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_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())
}
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],
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<DashMap<DBProductId, Vec<DBProductPrice>>, DatabaseError> {
@ -240,7 +246,8 @@ impl DBProductPrice {
use futures_util::TryStreamExt;
let prices = select_prices_with_predicate!(
"WHERE product_id = ANY($1::bigint[])",
"WHERE product_id = ANY($1::bigint[])
AND public = true",
ids_ref
)
.fetch(exec)

View File

@ -145,7 +145,7 @@ impl RedeemalLookupFields {
///
/// 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_user_username_and_offer<'a, E>(
pub async fn redeemal_status_by_username_and_offer<'a, E>(
exec: E,
user_username: &str,
offer: Offer,
@ -183,7 +183,7 @@ impl RedeemalLookupFields {
redeemal_status: row
.status
.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>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
let products =
product_item::QueryProductWithPrices::list(&**pool, &redis).await?;
let products = product_item::QueryProductWithPrices::list_purchaseable(
&**pool, &redis,
)
.await?;
let products = products
.into_iter()
@ -408,7 +410,7 @@ pub async fn edit_subscription(
let intent = if let Some(product_id) = &edit_subscription.product {
let product_price =
product_item::DBProductPrice::get_all_product_prices(
product_item::DBProductPrice::get_all_public_product_prices(
(*product_id).into(),
&mut *transaction,
)
@ -1187,7 +1189,7 @@ pub async fn initiate_payment(
})?;
let mut product_prices =
product_item::DBProductPrice::get_all_product_prices(
product_item::DBProductPrice::get_all_public_product_prices(
product.id, &**pool,
)
.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 chrono::Utc;
use serde::{Deserialize, Serialize};
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::util::guards::medal_key_guard;
@ -12,18 +15,17 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
#[derive(Deserialize)]
struct MedalVerifyQuery {
struct MedalQuery {
username: String,
}
#[post("verify", guard = "medal_key_guard")]
pub async fn verify(
_req: HttpRequest,
pool: web::Data<PgPool>,
web::Query(MedalVerifyQuery { username }): web::Query<MedalVerifyQuery>,
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
) -> Result<HttpResponse, ApiError> {
let maybe_fields =
RedeemalLookupFields::redeemal_status_by_user_username_and_offer(
RedeemalLookupFields::redeemal_status_by_username_and_offer(
&**pool,
&username,
Offer::Medal,
@ -47,8 +49,44 @@ pub async fn verify(
#[post("redeem", guard = "medal_key_guard")]
pub async fn redeem(
_req: HttpRequest,
_pool: web::Data<PgPool>,
pool: web::Data<PgPool>,
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
) -> 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),
#[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,