diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index b1e4599..706a594 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -87,9 +87,9 @@
(span
(text "{{ dislikes }}"))
(text "{%- endif %}"))
-(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
+(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true) -%} {% if user and user.username -%}")
(div
- ("class" "flex flex_wrap items_center")
+ ("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center")
(a
("href" "/@{{ user.username }}")
("class" "flush flex gap_1")
diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp
index 5458233..2ffd755 100644
--- a/crates/app/src/public/html/economy/wallet.lisp
+++ b/crates/app/src/public/html/economy/wallet.lisp
@@ -49,24 +49,36 @@
(th (text "Sender"))
(th (text "Receiver"))
(th (text "Amount"))
- (th (text "Product")))
+ (th (text "Product"))
+ (th (text "Source"))
+ (th (text "Actions")))
(tbody
(text "{% for transfer in list -%}")
(tr
- (td (span ("class" "date short") (text "{{ transfer[1] }}")))
- (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}"))
- (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}"))
+ (td (span ("class" "date short") (text "{{ transfer[3].created }}")))
+ (td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}"))
+ (td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}"))
(td
("class" "flex items_center gap_1")
- (text "{{ transfer[2] }}")
- (text "{% if transfer[6] -%}")
+ (text "{{ transfer[3].amount }}")
+ (text "{% if transfer[3].is_pending -%}")
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
(text "{%- endif %}"))
(td
- (text "{% if transfer[5] -%}")
+ (text "{% if transfer[2] -%}")
(a
- ("href" "/product/{{ transfer[5].id }}")
+ ("href" "/product/{{ transfer[2].id }}")
(icon (text "external-link")))
+ (text "{%- endif %}"))
+ (td (text "{{ transfer[3].source }}"))
+ (td
+ (text "{% if user.id == transfer[1].id -%}")
+ ; we're the receiver
+ (button
+ ("class" "small tiny square raised camo big_icon")
+ ("onclick" "issue_refund('{{ transfer[3].id }}')")
+ ("title" "Issue refund")
+ (icon (text "undo")))
(text "{%- endif %}")))
(text "{%- endfor %}")))))))
@@ -118,5 +130,23 @@
window.location.href = res.payload;
}
});
+ }
+
+ globalThis.issue_refund = async (transfer) => {
+ if (
+ !(await trigger(\"atto::confirm\", [
+ \"Are you sure you would like to do this?\",
+ ]))
+ ) {
+ return;
+ }
+
+ fetch(`/api/v1/transfers/${transfer}/refund`, {
+ method: \"POST\",
+ })
+ .then((res) => res.json())
+ .then((res) => {
+ trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
+ });
}"))
(text "{% endblock %}")
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 7ce8444..4a64b2f 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -727,6 +727,10 @@ pub fn routes() -> Router {
// transfers
.route("/transfers", post(transfers::create_request))
.route("/transfers/ask", post(transfers::ask_request))
+ .route(
+ "/transfers/{id}/refund",
+ post(transfers::create_refund_request),
+ )
// products
.route("/products", post(products::create_request))
.route("/products/{id}", delete(products::delete_request))
@@ -751,6 +755,14 @@ pub fn routes() -> Router {
post(products::update_method_request),
)
.route("/products/{id}/stock", post(products::update_stock_request))
+ .route(
+ "/products/{id}/uploads",
+ post(products::update_uploads_request),
+ )
+ .route(
+ "/products/{id}/uploads/thumbnails",
+ delete(products::remove_thumbnail_request),
+ )
}
pub fn lw_routes() -> Router {
@@ -1323,3 +1335,19 @@ pub struct AddAppliedConfiguration {
pub struct RemoveAppliedConfiguration {
pub id: String,
}
+
+#[derive(Deserialize, PartialEq, Eq)]
+pub enum ProductUploadTarget {
+ Thumbnails,
+ Reward,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateProductUploads {
+ pub target: ProductUploadTarget,
+}
+
+#[derive(Deserialize)]
+pub struct RemoveProductThumbnail {
+ pub idx: usize,
+}
diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs
index b255de6..03e319c 100644
--- a/crates/app/src/routes/api/v1/products.rs
+++ b/crates/app/src/routes/api/v1/products.rs
@@ -1,15 +1,21 @@
-use crate::{get_user_from_token, State, cookie::CookieJar};
+use crate::{
+ cookie::CookieJar,
+ get_user_from_token,
+ image::{save_webp_buffer, JsonMultipart},
+ State,
+};
use axum::{extract::Path, response::IntoResponse, Extension, Json};
use tetratto_core::model::{
economy::{Product, ProductFulfillmentMethod},
oauth,
permissions::FinePermission,
+ uploads::{MediaType, MediaUpload},
ApiReturn, Error,
};
use super::{
- CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod,
- UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock,
- UpdateProductTitle,
+ CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData,
+ UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
+ UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
};
pub async fn create_request(
@@ -296,3 +302,163 @@ pub async fn buy_request(
Err(e) => Json(e.into()),
}
}
+
+const MAXIMUM_THUMBNAIL_FILE_SIZE: usize = 2_097_152;
+const MAXIMUM_REWARD_FILE_SIZE: usize = 4_194_304;
+
+/// Update the product's uploads. Only reads one multipart file entry.
+pub async fn update_uploads_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Path(id): Path,
+ JsonMultipart(bytes_parts, req): JsonMultipart,
+) -> 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()),
+ };
+
+ let mut product = match data.get_product_by_id(id).await {
+ Ok(x) => x,
+ Err(e) => return Json(e.into()),
+ };
+
+ if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
+ return Json(Error::NotAllowed.into());
+ }
+
+ // apply to target
+ match req.target {
+ ProductUploadTarget::Thumbnails => {
+ if product.uploads.thumbnails.len() == 4 {
+ return Json(
+ Error::MiscError("Too many thumbnails exist. Please remove one".to_string())
+ .into(),
+ );
+ }
+
+ // create upload
+ let file = match bytes_parts.get(0) {
+ Some(x) => x,
+ None => return Json(Error::Unknown.into()),
+ };
+
+ if file.len() > MAXIMUM_THUMBNAIL_FILE_SIZE {
+ return Json(Error::FileTooLarge.into());
+ }
+
+ let upload = match data
+ .create_upload(MediaUpload::new(MediaType::Webp, user.id))
+ .await
+ {
+ Ok(x) => x,
+ Err(e) => return Json(e.into()),
+ };
+
+ product.uploads.thumbnails.push(upload.id);
+
+ // write image
+ if let Err(e) =
+ save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
+ {
+ return Json(Error::MiscError(e.to_string()).into());
+ }
+ }
+ ProductUploadTarget::Reward => {
+ // remove old
+ if product.uploads.reward != 0 {
+ if let Err(e) = data.delete_upload(product.uploads.reward).await {
+ return Json(e.into());
+ }
+ }
+
+ // create upload
+ let file = match bytes_parts.get(0) {
+ Some(x) => x,
+ None => return Json(Error::Unknown.into()),
+ };
+
+ if file.len() > MAXIMUM_REWARD_FILE_SIZE {
+ return Json(Error::FileTooLarge.into());
+ }
+
+ let upload = match data
+ .create_upload(MediaUpload::new(MediaType::Webp, user.id))
+ .await
+ {
+ Ok(x) => x,
+ Err(e) => return Json(e.into()),
+ };
+
+ product.uploads.reward = upload.id;
+
+ // write image
+ if let Err(e) =
+ save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
+ {
+ return Json(Error::MiscError(e.to_string()).into());
+ }
+ }
+ }
+
+ // ...
+ match data
+ .update_product_uploads(id, &user, product.uploads)
+ .await
+ {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Product updated".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
+
+pub async fn remove_thumbnail_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()),
+ };
+
+ let mut product = match data.get_product_by_id(id).await {
+ Ok(x) => x,
+ Err(e) => return Json(e.into()),
+ };
+
+ if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
+ return Json(Error::NotAllowed.into());
+ }
+
+ // remove upload
+ let thumbnail = match product.uploads.thumbnails.get(req.idx) {
+ Some(x) => x,
+ None => return Json(Error::GeneralNotFound("thumbnail".to_string()).into()),
+ };
+
+ if let Err(e) = data.delete_upload(*thumbnail).await {
+ return Json(e.into());
+ }
+
+ product.uploads.thumbnails.remove(req.idx);
+
+ // ...
+ match data
+ .update_product_uploads(id, &user, product.uploads)
+ .await
+ {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Product updated".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs
index 0995006..9547c50 100644
--- a/crates/app/src/routes/api/v1/transfers.rs
+++ b/crates/app/src/routes/api/v1/transfers.rs
@@ -1,5 +1,5 @@
use crate::{get_user_from_token, State, cookie::CookieJar};
-use axum::{response::IntoResponse, Extension, Json};
+use axum::{response::IntoResponse, Extension, Json, extract::Path};
use tetratto_core::model::{
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
oauth,
@@ -75,3 +75,46 @@ pub async fn ask_request(
Err(e) => Json(e.into()),
}
}
+
+pub async fn create_refund_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+ Path(id): Path,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ let other_transfer = match data.get_transfer_by_id(id).await {
+ Ok(x) => x,
+ Err(e) => return Json(e.into()),
+ };
+
+ if user.id != other_transfer.receiver {
+ // only the receiver of the funds can issue a refund (atm)
+ return Json(Error::NotAllowed.into());
+ }
+
+ match data
+ .create_transfer(
+ &mut CoinTransfer::new(
+ other_transfer.receiver,
+ other_transfer.sender,
+ other_transfer.amount,
+ CoinTransferMethod::Transfer,
+ CoinTransferSource::Refund,
+ ),
+ true,
+ )
+ .await
+ {
+ Ok(s) => Json(ApiReturn {
+ ok: true,
+ message: "Transfer created".to_string(),
+ payload: s.to_string(),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql
index 5d3afe8..2f27dcd 100644
--- a/crates/core/src/database/drivers/sql/create_products.sql
+++ b/crates/core/src/database/drivers/sql/create_products.sql
@@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS products (
price INT NOT NULL,
stock INT NOT NULL,
single_use INT NOT NULL,
- data TEXT NOT NULL
+ data TEXT NOT NULL,
+ uploads TEXT 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 f1e95a3..fb70e6b 100644
--- a/crates/core/src/database/drivers/sql/version_migrations.sql
+++ b/crates/core/src/database/drivers/sql/version_migrations.sql
@@ -54,10 +54,14 @@ ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
ALTER TABLE transfers
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
--- products single_use
+-- products data
ALTER TABLE products
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
-- users applied_configurations
ALTER TABLE users
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';
+
+-- products uploads
+ALTER TABLE products
+ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}';
diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs
index de18074..24a2405 100644
--- a/crates/core/src/database/products.rs
+++ b/crates/core/src/database/products.rs
@@ -2,6 +2,7 @@ use crate::model::{
auth::User,
economy::{
CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod,
+ ProductUploads,
},
mail::Letter,
permissions::FinePermission,
@@ -25,6 +26,7 @@ impl DataManager {
stock: get!(x->8(i32)),
single_use: get!(x->9(i32)) as i8 == 1,
data: get!(x->10(String)),
+ uploads: serde_json::from_str(&get!(x->11(String))).unwrap(),
}
}
@@ -107,7 +109,7 @@ impl DataManager {
let res = execute!(
&conn,
- "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
+ "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
params![
&(data.id as i64),
&(data.created as i64),
@@ -120,6 +122,7 @@ impl DataManager {
&(data.stock as i32),
&{ if data.single_use { 1 } else { 0 } },
&data.data,
+ &serde_json::to_string(&data.uploads).unwrap(),
]
);
@@ -271,6 +274,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th
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_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
+ auto_method!(update_product_uploads(ProductUploads)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET uploads = $1 WHERE id = $2" --serde --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 ecbde84..4653166 100644
--- a/crates/core/src/database/transfers.rs
+++ b/crates/core/src/database/transfers.rs
@@ -1,8 +1,8 @@
use std::collections::HashMap;
use crate::model::{
- Error, Result,
- economy::{CoinTransferMethod, Product, CoinTransfer},
auth::{Notification, User},
+ economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product},
+ Error, Result,
};
use crate::{auto_method, DataManager};
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
@@ -28,16 +28,13 @@ impl DataManager {
pub async fn fill_transfers(
&self,
list: Vec,
- ) -> Result, bool)>> {
+ ) -> Result, CoinTransfer)>> {
let mut out = Vec::new();
let mut seen_users: HashMap = HashMap::new();
let mut seen_products: HashMap = HashMap::new();
for transfer in list {
out.push((
- transfer.id,
- transfer.created,
- transfer.amount,
if let Some(user) = seen_users.get(&transfer.sender) {
user.to_owned()
} else {
@@ -68,7 +65,7 @@ impl DataManager {
}
}
},
- transfer.is_pending,
+ transfer,
));
}
@@ -163,6 +160,16 @@ impl DataManager {
}
self.update_user_coins(receiver.id, receiver.coins).await?;
+
+ // handle refund notification
+ if data.source == CoinTransferSource::Refund {
+ self.create_notification(Notification::new(
+ "A coin refund has been issued to your account!".to_string(),
+ "You've been issued a refund for a prior purchase. The product will remain in your account, but your coins have been returned.".to_string(),
+ receiver.id,
+ ))
+ .await?;
+ }
} else {
// we haven't applied the transfer, so this must be pending
data.is_pending = true;
diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs
index 4b768b2..20ee0b2 100644
--- a/crates/core/src/model/economy.rs
+++ b/crates/core/src/model/economy.rs
@@ -16,6 +16,29 @@ pub enum ProductFulfillmentMethod {
ProfileStyle,
}
+#[derive(Clone, Serialize, Deserialize)]
+pub struct ProductUploads {
+ /// Promotional thumbnails shown on the product page.
+ ///
+ /// Maximum of 4 with a maximum upload size of 2 MiB.
+ #[serde(default)]
+ pub thumbnails: Vec,
+ /// Reward given to users through active configurations after they purchase the product.
+ //
+ // Maximum upload size of 4 MiB.
+ #[serde(default)]
+ pub reward: usize,
+}
+
+impl Default for ProductUploads {
+ fn default() -> Self {
+ Self {
+ thumbnails: Vec::new(),
+ reward: 0,
+ }
+ }
+}
+
#[derive(Clone, Serialize, Deserialize)]
pub struct Product {
pub id: usize,
@@ -34,9 +57,14 @@ 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.
+ #[serde(default)]
pub single_use: bool,
/// Data for this product. Only used by snippets.
+ #[serde(default)]
pub data: String,
+ /// Uploads for this product.
+ #[serde(default)]
+ pub uploads: ProductUploads,
}
impl Product {
@@ -54,6 +82,7 @@ impl Product {
stock: 0,
single_use: true,
data: String::new(),
+ uploads: ProductUploads::default(),
}
}
}
@@ -65,7 +94,7 @@ pub enum CoinTransferMethod {
Purchase(usize),
}
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum CoinTransferSource {
/// An unknown source, such as a transfer request.
General,
@@ -73,6 +102,8 @@ pub enum CoinTransferSource {
Sale,
/// A purchase of coins through Stripe.
Purchase,
+ /// A refund of coins.
+ Refund,
}
#[derive(Serialize, Deserialize)]