diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 73507f4..8a96808 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -337,9 +337,11 @@ version = "1.0.0" "economy:label.create_new" = "Create new product" "economy:label.price" = "Price" "economy:label.on_sale" = "On sale" +"economy:label.single_use" = "Only allow users to purchase once" "economy:label.stock" = "Stock" "economy:label.unlimited" = "Unlimited" "economy:label.fulfillment_style" = "Fulfillment style" "economy:label.use_automail" = "Use automail" "economy:label.automail_message" = "Automail message" "economy:action.buy" = "Buy" +"economy:label.already_purchased" = "Already purchased" diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index 30accd2..ec94f12 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -77,6 +77,18 @@ ("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)")) (span (str (text "economy:label.on_sale")))) + (label + ("for" "single_use") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "single_use") + ("name" "single_use") + ("class" "w_content") + ("checked" "{{ product.single_use }}") + ("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)")) + (span + (str (text "economy:label.single_use")))) (div ("class" "flex flex_col gap_1") (label @@ -240,6 +252,27 @@ }); } + async function update_single_use_from_form(single_use) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/single_use\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + single_use, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + async function update_price_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"products::update\"]); diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp index bcceb2b..7a1ce86 100644 --- a/crates/app/src/public/html/economy/product.lisp +++ b/crates/app/src/public/html/economy/product.lisp @@ -28,12 +28,19 @@ (icon (text "badge-cent")) (text "{{ product.price }}")) (text "{% if user.id != product.owner -%}") + (text "{% if not already_purchased -%}") (button ("onclick" "purchase()") ("disabled" "{{ product.stock == 0 }}") (icon (text "piggy-bank")) (str (text "economy:action.buy"))) (text "{% else %}") + (span + ("class" "green flex items_center gap_2") + (icon (text "circle-check")) + (str (text "economy:label.already_purchased"))) + (text "{%- endif %}") + (text "{% else %}") (a ("class" "button") ("href" "/product/{{ product.id }}/edit") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index c1788b3..e4e230b 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -304,7 +304,7 @@ globalThis.request_transfer = async () => { await trigger(\"atto::debounce\", [\"economy::transfer\"]); - const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\"); + const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); if (amount === 0) { return; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3a3b081..d24b953 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -732,6 +732,10 @@ pub fn routes() -> Router { "/products/{id}/on_sale", post(products::update_on_sale_request), ) + .route( + "/products/{id}/single_use", + post(products::update_single_use_request), + ) .route("/products/{id}/price", post(products::update_price_request)) .route( "/products/{id}/method", @@ -1275,6 +1279,11 @@ pub struct UpdateProductOnSale { pub on_sale: bool, } +#[derive(Deserialize)] +pub struct UpdateProductSingleUse { + pub single_use: bool, +} + #[derive(Deserialize)] pub struct UpdateProductPrice { pub price: i32, diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index e532d01..71bc962 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -3,7 +3,7 @@ use axum::{extract::Path, response::IntoResponse, Extension, Json}; use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error}; use super::{ CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, - UpdateProductPrice, UpdateProductStock, UpdateProductTitle, + UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, }; pub async fn create_request( @@ -137,6 +137,31 @@ pub async fn update_on_sale_request( } } +pub async fn update_single_use_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_product_single_use(id, &user, if req.single_use { 1 } else { 0 }) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_price_request( jar: CookieJar, Extension(data): Extension, diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index 36c9cbe..402f03f 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -4,7 +4,7 @@ use axum::{ Extension, }; use crate::cookie::CookieJar; -use tetratto_core::model::Error; +use tetratto_core::model::{economy::CoinTransferMethod, Error}; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use super::{render_error, PaginatedQuery}; @@ -138,11 +138,21 @@ pub async fn product_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + let already_purchased = if product.single_use { + data.0 + .get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id)) + .await + .is_ok() + } else { + false + }; + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("product", &product); context.insert("owner", &owner); + context.insert("already_purchased", &already_purchased); // return Ok(Html( diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index be32f3c..b1ce39b 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS products ( method TEXT NOT NULL, on_sale INT NOT NULL, price INT NOT NULL, - stock INT NOT NULL + stock INT NOT NULL, + single_use INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index a935589..e4f30d5 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -45,3 +45,7 @@ ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"'; -- users checkouts ALTER TABLE users ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]'; + +-- products single_use +ALTER TABLE products +ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 714531d..f888e07 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -21,6 +21,7 @@ impl DataManager { on_sale: get!(x->6(i32)) as i8 == 1, price: get!(x->7(i32)), stock: get!(x->8(i32)), + single_use: get!(x->9(i32)) as i8 == 1, } } @@ -103,7 +104,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.created as i64), @@ -113,7 +114,8 @@ impl DataManager { &serde_json::to_string(&data.method).unwrap(), &{ if data.on_sale { 1 } else { 0 } }, &data.price, - &(data.stock as i32) + &(data.stock as i32), + &{ if data.single_use { 1 } else { 0 } }, ] ); @@ -131,6 +133,22 @@ impl DataManager { customer: &mut User, ) -> Result { let product = self.get_product_by_id(product).await?; + + // handle single_use product + if product.single_use { + if self + .get_transfer_by_sender_method( + customer.id, + CoinTransferMethod::Purchase(product.id), + ) + .await + .is_ok() + { + return Err(Error::MiscError("You already own this product".to_string())); + } + } + + // ... let mut transfer = CoinTransfer::new( customer.id, product.owner, @@ -231,6 +249,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr); diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index 40507b8..3668695 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -5,7 +5,7 @@ use crate::model::{ auth::{Notification, User}, }; use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; impl DataManager { /// Get a [`CoinTransfer`] from an SQL row. @@ -101,6 +101,35 @@ impl DataManager { Ok(res.unwrap()) } + /// Get a transfer by user and method. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch transfers for + /// * `method` - the transfer method + pub async fn get_transfer_by_sender_method( + &self, + id: usize, + method: CoinTransferMethod, + ) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM transfers WHERE sender = $1 AND method = $2 LIMIT 1", + params![&(id as i64), &serde_json::to_string(&method).unwrap()], + |x| { Ok(Self::get_transfer_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("transfer".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new transfer in the database. /// /// # Arguments diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index c7ed372..15cf435 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -29,6 +29,8 @@ pub struct Product { /// /// A negative stock means the product has unlimited stock. pub stock: i32, + /// If this product is limited to one purchase per person. + pub single_use: bool, } impl Product { @@ -44,6 +46,7 @@ impl Product { on_sale: false, price: 0, stock: 0, + single_use: true, } } }