From df5eaf24f74b7931d9236a5264d4ac6360d200b1 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 7 Aug 2025 13:52:48 -0400 Subject: [PATCH] add: products api --- crates/app/src/assets.rs | 4 + crates/app/src/langs/en-US.toml | 3 + crates/app/src/public/css/style.css | 10 + .../app/src/public/html/economy/wallet.lisp | 65 +++++ crates/app/src/routes/api/v1/letters.rs | 7 + crates/app/src/routes/api/v1/mod.rs | 65 ++++- crates/app/src/routes/api/v1/products.rs | 225 +++++++++++++++++ .../api/v1/{economy.rs => transfers.rs} | 19 +- crates/app/src/routes/pages/economy.rs | 45 ++++ crates/app/src/routes/pages/mod.rs | 3 + crates/core/src/config.rs | 6 + crates/core/src/database/common.rs | 2 + crates/core/src/database/drivers/common.rs | 2 + .../database/drivers/sql/create_products.sql | 11 + .../database/drivers/sql/create_requests.sql | 1 + .../database/drivers/sql/create_transfers.sql | 9 + .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/economy.rs | 91 ------- crates/core/src/database/memberships.rs | 1 + crates/core/src/database/mod.rs | 3 +- crates/core/src/database/products.rs | 228 ++++++++++++++++++ crates/core/src/database/questions.rs | 1 + crates/core/src/database/requests.rs | 4 +- crates/core/src/database/transfers.rs | 175 ++++++++++++++ crates/core/src/database/userfollows.rs | 1 + crates/core/src/model/economy.rs | 60 ++++- crates/core/src/model/oauth.rs | 12 +- crates/core/src/model/requests.rs | 74 +++++- example/tetratto.toml | 1 + 29 files changed, 1022 insertions(+), 110 deletions(-) create mode 100644 crates/app/src/public/html/economy/wallet.lisp create mode 100644 crates/app/src/routes/api/v1/products.rs rename crates/app/src/routes/api/v1/{economy.rs => transfers.rs} (55%) create mode 100644 crates/app/src/routes/pages/economy.rs create mode 100644 crates/core/src/database/drivers/sql/create_products.sql create mode 100644 crates/core/src/database/drivers/sql/create_transfers.sql delete mode 100644 crates/core/src/database/economy.rs create mode 100644 crates/core/src/database/products.rs create mode 100644 crates/core/src/database/transfers.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index c8db3be..d39bf2e 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -147,6 +147,8 @@ pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp"); pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp"); pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp"); +pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -379,6 +381,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins); write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins); + write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 523d7f7..0c778cd 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -20,6 +20,7 @@ version = "1.0.0" "general:link.achievements" = "Achievements" "general:link.little_web" = "Little web" "general:link.mail" = "Mail" +"general:link.wallet" = "Wallet" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -324,3 +325,5 @@ version = "1.0.0" "mail:label.content" = "Content" "mail:action.send" = "Send" "mail:action.send_mail" = "Send mail" + +"economy:label.recent_transfers" = "Recent transfers" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 1333485..1e9afe0 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -272,6 +272,16 @@ table ol { } } +.card.button { + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + gap: var(--pad-2); + width: 100%; + height: max-content; + padding: var(--pad-4); +} + /* supporter card */ @property --border-angle { syntax: ""; diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp new file mode 100644 index 0000000..e390502 --- /dev/null +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -0,0 +1,65 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Wallet - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"wallet\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "piggy-bank")) + (span (str (text "general:link.wallet"))))) + (div + ("class" "card lowered flex flex_col gap_4") + (a + ("class" "card button raised") + ("href" "/wallet/buy") + (b (text "Coin balance")) + (h3 + ("class" "flex gap_2 items_center") + ("style" "height: 24px") + (icon (text "badge-cent")) + (text "{{ user.coins }}"))))) + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "clock")) + (span (str (text "economy:label.recent_transfers"))))) + (div + ("class" "card lowered flex flex_col gap_4") + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "Created")) + (th (text "Sender")) + (th (text "Receiver")) + (th (text "Amount")) + (th (text "Product"))) + (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 + (text "{{ transfer[2] }}") + (text "{% if transfer[6] -%}") + (span ("title" "Pending") (icon (text "clock"))) + (text "{%- endif %}")) + (td + (text "{% if transfer[5] -%}") + (a + ("href" "/product/{{ transfer[5].id }}") + (icon (text "external-link"))) + (text "{%- endif %}"))) + (text "{%- endfor %}"))))))) +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/letters.rs b/crates/app/src/routes/api/v1/letters.rs index 9ab3d36..0d00749 100644 --- a/crates/app/src/routes/api/v1/letters.rs +++ b/crates/app/src/routes/api/v1/letters.rs @@ -187,6 +187,13 @@ pub async fn create_request( } } + // check if we're fulfilling a coin transfer + if props.transfer_id != 0 { + if let Err(e) = data.apply_transfer(props.transfer_id).await { + return Json(e.into()); + } + } + // ... Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2dc9d6c..9ce76ef 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,16 +4,17 @@ pub mod auth; pub mod channels; pub mod communities; pub mod domains; -pub mod economy; pub mod journals; pub mod letters; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; pub mod services; pub mod stacks; +pub mod transfers; pub mod uploads; pub mod util; @@ -30,6 +31,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + economy::ProductFulfillmentMethod, journals::JournalPrivacyPermission, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, @@ -706,8 +708,27 @@ pub fn routes() -> Router { .route("/letters/{id}/read", post(letters::add_read_request)) .route("/letters/sent", get(letters::list_sent_request)) .route("/letters/received", get(letters::list_received_request)) - // economy - .route("/transfers", post(economy::create_request)) + // transfers + .route("/transfers", post(transfers::create_request)) + // products + .route("/products", post(products::create_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/buy", post(products::buy_request)) + .route("/products/{id}/title", post(products::update_title_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route( + "/products/{id}/on_sale", + post(products::update_on_sale_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) + .route( + "/products/{id}/method", + post(products::update_method_request), + ) + .route("/products/{id}/stock", post(products::update_stock_request)) } pub fn lw_routes() -> Router { @@ -1214,6 +1235,8 @@ pub struct CreateLetter { pub subject: String, pub content: String, pub replying_to: String, + #[serde(default)] + pub transfer_id: usize, } #[derive(Deserialize)] @@ -1221,3 +1244,39 @@ pub struct CreateCoinTransfer { pub receiver: usize, pub amount: i32, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub title: String, + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductOnSale { + pub on_sale: bool, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: i32, +} + +#[derive(Deserialize)] +pub struct UpdateProductMethod { + pub method: ProductFulfillmentMethod, +} + +#[derive(Deserialize)] +pub struct UpdateProductStock { + pub stock: i32, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs new file mode 100644 index 0000000..d377de8 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,225 @@ +use crate::{get_user_from_token, State, cookie::CookieJar}; +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, +}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_product(Product::new(user.id, req.title, req.description)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_product(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut 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()), + }; + + req.title = req.title.trim().to_string(); + if req.title.len() < 2 { + return Json(Error::DataTooShort("title".to_string()).into()); + } else if req.title.len() > 128 { + return Json(Error::DataTooLong("title".to_string()).into()); + } + + match data.update_product_title(id, &user, &req.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_description_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut 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()), + }; + + req.description = req.description.trim().to_string(); + if req.description.len() < 2 { + return Json(Error::DataTooShort("description".to_string()).into()); + } else if req.description.len() > 1024 { + return Json(Error::DataTooLong("description".to_string()).into()); + } + + match data + .update_product_description(id, &user, &req.description) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_on_sale_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_on_sale(id, &user, if req.on_sale { 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, + 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_price(id, &user, req.price).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_method_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_method(id, &user, req.method).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_stock_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_stock(id, &user, req.stock).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn buy_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.purchase_product(id, &mut user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product purchased".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/economy.rs b/crates/app/src/routes/api/v1/transfers.rs similarity index 55% rename from crates/app/src/routes/api/v1/economy.rs rename to crates/app/src/routes/api/v1/transfers.rs index fa7bf4a..5de916f 100644 --- a/crates/app/src/routes/api/v1/economy.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -1,6 +1,9 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; use axum::{response::IntoResponse, Extension, Json}; -use tetratto_core::model::{economy::CoinTransfer, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + economy::{CoinTransfer, CoinTransferMethod}, + oauth, ApiReturn, Error, +}; use super::CreateCoinTransfer; pub async fn create_request( @@ -15,13 +18,21 @@ pub async fn create_request( }; match data - .create_transfer(CoinTransfer::new(user.id, req.receiver, req.amount)) + .create_transfer( + &mut CoinTransfer::new( + user.id, + req.receiver, + req.amount, + CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method + ), + true, + ) .await { Ok(s) => Json(ApiReturn { ok: true, - message: "Stack created".to_string(), - payload: s.id.to_string(), + message: "Transfer created".to_string(), + payload: s.to_string(), }), Err(e) => Json(e.into()), } diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs new file mode 100644 index 0000000..ea6f6f6 --- /dev/null +++ b/crates/app/src/routes/pages/economy.rs @@ -0,0 +1,45 @@ +use axum::{ + extract::{Query, Path}, + response::{Html, IntoResponse}, + Extension, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::Error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use super::{render_error, PaginatedQuery}; + +/// `/wallet` +pub async fn wallet_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let list = match data.0.get_transfers_by_user(user.id, 12, props.page).await { + Ok(x) => match data.0.fill_transfers(x).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("page", &props.page); + + // return + Ok(Html( + data.1.render("economy/wallet.html", &context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 93cfc9a..3cdae39 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod chats; pub mod communities; pub mod developer; +pub mod economy; pub mod forge; pub mod journals; pub mod littleweb; @@ -159,6 +160,8 @@ pub fn routes() -> Router { .route("/mail/sent", get(mail::sent_request)) .route("/mail/compose", get(mail::compose_request)) .route("/mail/letter/{id}", get(mail::letter_request)) + // economy + .route("/wallet", get(economy::wallet_request)) } pub fn lw_routes() -> Router { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 4924714..fb04c7b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -315,6 +315,9 @@ pub struct Config { /// to post in by default. This should be some sort of "general" topic. #[serde(default)] pub town_square_forum_topic: usize, + /// The ID of the "system" user which will send system mails to users. + #[serde(default)] + pub system_user: usize, #[serde(default)] pub connections: ConnectionsConfig, /// The path to the HTML footer file. The contents of this file are embedded @@ -396,6 +399,8 @@ fn default_banned_usernames() -> Vec { "services".to_string(), "domains".to_string(), "mail".to_string(), + "product".to_string(), + "wallet".to_string(), ] } @@ -439,6 +444,7 @@ impl Default for Config { town_square: 0, town_square_forum: 0, town_square_forum_topic: 0, + system_user: 0, connections: default_connections(), html_footer_path: String::new(), stripe: None, diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 71e792d..4d3fe55 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); + execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap(); + execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); for x in common::VERSION_MIGRATIONS.split(";") { execute!(&conn, x).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 4881179..7c1b2e5 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -32,3 +32,5 @@ pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql"); +pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.sql"); +pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql new file mode 100644 index 0000000..be32f3c --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS products ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + method TEXT NOT NULL, + on_sale INT NOT NULL, + price INT NOT NULL, + stock INT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_requests.sql b/crates/core/src/database/drivers/sql/create_requests.sql index ef5d83e..98bbdcb 100644 --- a/crates/core/src/database/drivers/sql/create_requests.sql +++ b/crates/core/src/database/drivers/sql/create_requests.sql @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS requests ( owner BIGINT NOT NULL, action_type TEXT NOT NULL, linked_asset BIGINT NOT NULL, + data TEXT NOT NULL, PRIMARY KEY (id, owner, linked_asset) ) diff --git a/crates/core/src/database/drivers/sql/create_transfers.sql b/crates/core/src/database/drivers/sql/create_transfers.sql new file mode 100644 index 0000000..d747c78 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_transfers.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS transfers ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + sender BIGINT NOT NULL, + receiver BIGINT NOT NULL, + amount INT NOT NULL, + is_pending INT NOT NULL, + method 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 47bcb04..eecaba6 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -37,3 +37,7 @@ DROP COLUMN IF EXISTS seller_data; -- users coins ALTER TABLE users ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0; + +-- requests data +ALTER TABLE requests +ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"'; diff --git a/crates/core/src/database/economy.rs b/crates/core/src/database/economy.rs deleted file mode 100644 index f8efce4..0000000 --- a/crates/core/src/database/economy.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::model::economy::CoinTransfer; -use crate::model::{Error, Result}; -use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; - -impl DataManager { - /// Get a [`CoinTransfer`] from an SQL row. - pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { - CoinTransfer { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - sender: get!(x->2(i64)) as usize, - receiver: get!(x->3(i64)) as usize, - amount: get!(x->4(i32)), - } - } - - auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}"); - - /// Get all transfers by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch transfers for - /// * `batch` - the limit of items in each page - /// * `page` - the page number - pub async fn get_transfers_by_user( - &self, - id: usize, - batch: usize, - page: usize, - ) -> Result> { - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = query_rows!( - &conn, - "SELECT * FROM transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", - &[&(id as i64), &(batch as i64), &((page * batch) as i64)], - |x| { 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 - /// * `data` - a mock [`CoinTransfer`] object to insert - pub async fn create_transfer(&self, data: CoinTransfer) -> Result { - // check values - let mut sender = self.get_user_by_id(data.sender).await?; - let mut receiver = self.get_user_by_id(data.receiver).await?; - let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); - - if sender_bankrupt | receiver_bankrupt { - return Err(Error::MiscError( - "One party of this transfer cannot afford this".to_string(), - )); - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!( - &conn, - "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.sender as i64), - &(data.receiver as i64), - &data.amount - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } -} diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 610d0a0..e69de8d 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -200,6 +200,7 @@ impl DataManager { community.owner, ActionType::CommunityJoin, community.id, + None, )) .await?; diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c49a71f..c52a107 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -9,7 +9,6 @@ pub mod connections; mod domains; mod drafts; mod drivers; -mod economy; mod emojis; mod invite_codes; mod ipbans; @@ -24,6 +23,7 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; @@ -31,6 +31,7 @@ mod requests; mod services; mod stackblocks; mod stacks; +mod transfers; mod uploads; mod user_warnings; mod userblocks; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs new file mode 100644 index 0000000..82240da --- /dev/null +++ b/crates/core/src/database/products.rs @@ -0,0 +1,228 @@ +use crate::model::{ + auth::User, + economy::{CoinTransfer, CoinTransferMethod, Product, ProductFulfillmentMethod}, + mail::Letter, + permissions::FinePermission, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Product`] from an SQL row. + pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { + Product { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + description: get!(x->4(String)), + method: serde_json::from_str(&get!(x->5(String))).unwrap(), + on_sale: get!(x->6(i32)) as i8 == 1, + price: get!(x->7(i32)), + stock: get!(x->8(i32)), + } + } + + auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_products_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_PRODUCTS: usize = 5; + + /// Create a new product in the database. + /// + /// # Arguments + /// * `data` - a mock [`Product`] object to insert + pub async fn create_product(&self, mut data: Product) -> Result { + data.title = data.title.trim().to_string(); + data.description = data.description.trim().to_string(); + + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 128 { + return Err(Error::DataTooLong("title".to_string())); + } + + if data.description.len() < 2 { + return Err(Error::DataTooShort("description".to_string())); + } else if data.description.len() > 1024 { + return Err(Error::DataTooLong("description".to_string())); + } + + // check number of stacks + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let products = self + .get_table_row_count_where("products", &format!("owner = {}", owner.id)) + .await? as usize; + + if products >= Self::MAXIMUM_FREE_PRODUCTS { + return Err(Error::MiscError( + "You already have the maximum number of products you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &data.description, + &serde_json::to_string(&data.method).unwrap(), + &{ if data.on_sale { 1 } else { 0 } }, + &data.price, + &(data.stock as i32) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + /// Purchase the given product as the given user. + pub async fn purchase_product( + &self, + product: usize, + customer: &mut User, + ) -> Result { + let product = self.get_product_by_id(product).await?; + let mut transfer = CoinTransfer::new( + customer.id, + product.owner, + product.price, + CoinTransferMethod::Purchase(product.id), + ); + + if !product.stock.is_negative() { + // check stock + if product.stock == 0 { + return Err(Error::MiscError("No remaining stock".to_string())); + } else { + self.decr_product_stock(product.id).await?; + } + } + + match product.method { + ProductFulfillmentMethod::AutoMail(message) => { + // we're basically done, transfer coins and send mail + self.create_transfer(&mut transfer, false).await?; + + self.create_letter(Letter::new( + self.0.0.system_user, + vec![customer.id], + format!("Thank you for purchasing \"{}\"", product.title), + format!("The message below was supplied by the product owner, and was automatically sent.\n***\n{message}"), + 0, + )) + .await?; + + Ok(transfer) + } + ProductFulfillmentMethod::ManualMail => { + // mark transfer as pending and create it + self.create_transfer(&mut transfer, false).await?; + + // tell product owner they have a new pending purchase + self.create_letter(Letter::new( + self.0.0.system_user, + vec![product.owner], + "New product purchase pending".to_string(), + format!( + "Somebody has purchased your [product](/product/{}) \"{}\". Per your product's settings, the payment will not be completed until you manually mail them a letter **using the link below**. + +If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so. + +*** +Fulfill purchase", + product.id, product.title, customer.id, transfer.id + ), + 0, + )) + .await?; + + // return + Ok(transfer) + } + } + } + + pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { + let product = self.get_product_by_id(id).await?; + + // check user permission + if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM products WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.product:{}", id)).await; + Ok(()) + } + + auto_method!(update_product_title(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + 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_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); + auto_method!(decr_product_stock()@get_product_by_id -> "UPDATE products SET stock = stock - 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --decr=stock); +} diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 3703d4f..8372b95 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -506,6 +506,7 @@ impl DataManager { data.receiver, ActionType::Answer, data.id, + None, )) .await?; } diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 5a82062..84356ac 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -14,6 +14,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, action_type: serde_json::from_str(&get!(x->3(String))).unwrap(), linked_asset: get!(x->4(i64)) as usize, + data: serde_json::from_str(&get!(x->5(String))).unwrap(), } } @@ -118,13 +119,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO requests VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO requests VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.action_type).unwrap().as_str(), &(data.linked_asset as i64), + &serde_json::to_string(&data.data).unwrap().as_str(), ] ); diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs new file mode 100644 index 0000000..fb4ed6c --- /dev/null +++ b/crates/core/src/database/transfers.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; + +use crate::model::auth::User; +use crate::model::economy::{CoinTransferMethod, Product}; +use crate::model::{Error, Result, economy::CoinTransfer}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`CoinTransfer`] from an SQL row. + pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { + CoinTransfer { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + sender: get!(x->2(i64)) as usize, + receiver: get!(x->3(i64)) as usize, + amount: get!(x->4(i32)), + is_pending: get!(x->5(i32)) as i8 == 1, + method: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}"); + + /// Fill a list of transfers with their users and product. + pub async fn fill_transfers( + &self, + list: Vec, + ) -> Result, bool)>> { + 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 { + let user = self.get_user_by_id(transfer.sender).await?; + seen_users.insert(user.id, user.clone()); + user + }, + if let Some(user) = seen_users.get(&transfer.receiver) { + user.to_owned() + } else { + let user = self.get_user_by_id(transfer.receiver).await?; + seen_users.insert(user.id, user.clone()); + user + }, + match transfer.method { + CoinTransferMethod::Transfer => None, + CoinTransferMethod::Purchase(id) => { + Some(if let Some(product) = seen_products.get(&id) { + product.to_owned() + } else { + let product = self.get_product_by_id(id).await?; + seen_products.insert(product.id, product.clone()); + product + }) + } + }, + transfer.is_pending, + )); + } + + Ok(out) + } + + /// Get all transfers by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch transfers for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_transfers_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { 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 + /// * `data` - a mock [`CoinTransfer`] object to insert + pub async fn create_transfer(&self, data: &mut CoinTransfer, apply: bool) -> Result { + // check values + let mut sender = self.get_user_by_id(data.sender).await?; + let mut receiver = self.get_user_by_id(data.receiver).await?; + let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); + + if sender_bankrupt | receiver_bankrupt { + return Err(Error::MiscError( + "One party of this transfer cannot afford this".to_string(), + )); + } + + if apply { + self.update_user_coins(sender.id, sender.coins).await?; + self.update_user_coins(receiver.id, receiver.coins).await?; + } else { + // we haven't applied the transfer, so this must be pending + data.is_pending = true; + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.sender as i64), + &(data.receiver as i64), + &data.amount, + &{ if data.is_pending { 1 } else { 0 } }, + &serde_json::to_string(&data.method).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data.id) + } + + /// Apply a pending transfer. + pub async fn apply_transfer(&self, id: usize) -> Result<()> { + let transfer = self.get_transfer_by_id(id).await?; + + let mut sender = self.get_user_by_id(transfer.sender).await?; + let mut receiver = self.get_user_by_id(transfer.receiver).await?; + let (sender_bankrupt, receiver_bankrupt) = transfer.apply(&mut sender, &mut receiver); + + if sender_bankrupt | receiver_bankrupt { + return Err(Error::MiscError( + "One party of this transfer cannot afford this".to_string(), + )); + } + + self.update_user_coins(sender.id, sender.coins).await?; + self.update_user_coins(receiver.id, receiver.coins).await?; + self.update_transfer_is_pending(id, 0).await?; + Ok(()) + } + + auto_method!(update_transfer_is_pending(i32) -> "UPDATE products SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}"); +} diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 4b22835..fa358c0 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -276,6 +276,7 @@ impl DataManager { data.receiver, ActionType::Follow, data.receiver, + None, )) .await?; diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index 95d8e01..c7ed372 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -1,8 +1,60 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; - use super::auth::User; +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProductFulfillmentMethod { + /// Automatically send a letter to the customer with the specified content. + AutoMail(String), + /// Manually send a letter to the customer with the specified content. + /// + /// This will leave the [`CoinTransfer`] pending until you send this mail. + ManualMail, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Product { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub description: String, + /// How this product will be delivered. + pub method: ProductFulfillmentMethod, + /// If this product is actually for sale. + pub on_sale: bool, + /// The price of this product. + pub price: i32, + /// The number of times this product can be purchased. + /// + /// A negative stock means the product has unlimited stock. + pub stock: i32, +} + +impl Product { + /// Create a new [`Product`]. + pub fn new(owner: usize, title: String, description: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + description, + method: ProductFulfillmentMethod::ManualMail, + on_sale: false, + price: 0, + stock: 0, + } + } +} + +#[derive(Serialize, Deserialize)] +pub enum CoinTransferMethod { + Transfer, + /// A [`Product`] purchase with the product's ID. + Purchase(usize), +} + #[derive(Serialize, Deserialize)] pub struct CoinTransfer { pub id: usize, @@ -10,17 +62,21 @@ pub struct CoinTransfer { pub sender: usize, pub receiver: usize, pub amount: i32, + pub is_pending: bool, + pub method: CoinTransferMethod, } impl CoinTransfer { /// Create a new [`CoinTransfer`]. - pub fn new(sender: usize, receiver: usize, amount: i32) -> Self { + pub fn new(sender: usize, receiver: usize, amount: i32, method: CoinTransferMethod) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), sender, receiver, amount, + is_pending: false, + method, } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 357e4ea..1ebc28c 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,10 +74,10 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, - /// Read the user's products. - UserReadProducts, /// Read the user's letters. UserReadLetters, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -102,10 +102,10 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, - /// Create products on behalf of the user. - UserCreateProducts, /// Create letters on behalf of the user. UserCreateLetters, + /// Create products on behalf of the user. + UserCreateProducts, /// Send coins on behalf of the user. UserSendCoins, /// Delete posts owned by the user. @@ -148,12 +148,12 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, - /// Manage the user's products. - UserManageProducts, /// Manage the user's channel mutes. UserManageChannelMutes, /// Manage the user's letters. UserManageLetters, + /// Manage the user's products. + UserManageProducts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index 4ffcb78..1b4f320 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -1,6 +1,51 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum ActionData { + String(String), + Int32(i32), + Usize(usize), + Many(Vec), + Null, +} + +impl ActionData { + pub fn read_string(self) -> String { + match self { + ActionData::String(x) => x, + _ => String::default(), + } + } + + pub fn read_int32(self) -> i32 { + match self { + ActionData::Int32(x) => x, + _ => i32::default(), + } + } + + pub fn read_usize(self) -> usize { + match self { + ActionData::Usize(x) => x, + _ => usize::default(), + } + } + + pub fn read_many(self) -> Vec { + match self { + ActionData::Many(x) => x, + _ => Vec::default(), + } + } +} + +impl Default for ActionData { + fn default() -> Self { + Self::Null + } +} + #[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum ActionType { /// A request to join a community. @@ -15,6 +60,10 @@ pub enum ActionType { /// /// `users` table. Follow, + /// A request for the `owner` user (sender) to send the `linked_asset` user (receiver) coins. + /// + /// Expects a `data` value of [`ActionData::Int32`] representing the coin amount. + Transfer, } #[derive(Serialize, Deserialize)] @@ -26,28 +75,49 @@ pub struct ActionRequest { /// The ID of the asset this request links to. Should exist in the correct /// table for the given [`ActionType`]. pub linked_asset: usize, + /// Optional data attached to the action request. + pub data: ActionData, } impl ActionRequest { /// Create a new [`ActionRequest`]. - pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + pub fn new( + owner: usize, + action_type: ActionType, + linked_asset: usize, + data: Option, + ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), owner, action_type, linked_asset, + data: match data { + Some(x) => x, + None => ActionData::default(), + }, } } /// Create a new [`ActionRequest`] with the given `id`. - pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + pub fn with_id( + id: usize, + owner: usize, + action_type: ActionType, + linked_asset: usize, + data: Option, + ) -> Self { Self { id, created: unix_epoch_timestamp(), owner, action_type, linked_asset, + data: match data { + Some(x) => x, + None => ActionData::default(), + }, } } } diff --git a/example/tetratto.toml b/example/tetratto.toml index bc7ff59..a7c45e5 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -20,6 +20,7 @@ banned_usernames = [ ] town_square = 166340372315581657 html_footer_path = "public/footer.html" +system_user = 211903918383300608 [security] registration_enabled = true