From cf2af1e1e9e65a57b2f567ce223fd6a82899acd3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 15:28:55 -0400 Subject: [PATCH] add: products api --- crates/app/src/routes/api/v1/mod.rs | 36 ++++ crates/app/src/routes/api/v1/products.rs | 162 ++++++++++++++++++ .../database/drivers/sql/create_products.sql | 1 - crates/core/src/database/products.rs | 10 +- crates/core/src/model/oauth.rs | 6 + crates/core/src/model/products.rs | 35 +++- 6 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/routes/api/v1/products.rs diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ccc91c8..38f915e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -6,6 +6,7 @@ pub mod domains; pub mod journals; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; @@ -31,6 +32,7 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, + products::{ProductType, ProductPrice}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -652,6 +654,17 @@ pub fn routes() -> Router { .route("/domains/{id}", get(domains::get_request)) .route("/domains/{id}", delete(domains::delete_request)) .route("/domains/{id}/data", post(domains::update_data_request)) + // products + .route("/products", get(products::list_request)) + .route("/products", post(products::create_request)) + .route("/products/{id}", get(products::get_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/name", post(products::update_name_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) } pub fn lw_routes() -> Router { @@ -1086,3 +1099,26 @@ pub struct CreateDomain { pub struct UpdateDomainData { pub data: Vec<(String, DomainData)>, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub name: String, + pub description: String, + pub product_type: ProductType, + pub price: ProductPrice, +} + +#[derive(Deserialize)] +pub struct UpdateProductName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: ProductPrice, +} 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..5812127 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,162 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{products::Product, oauth, ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_product_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_products_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +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.name, + req.description, + req.price, + req.product_type, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_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_name(id, &user, &req.name).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(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_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_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 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::UserManageProducts) { + 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()), + } +} diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index ff45afc..54bec8d 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,6 +7,5 @@ CREATE TABLE IF NOT EXISTS products ( likes INT NOT NULL, dislikes INT NOT NULL, product_type TEXT NOT NULL, - stripe_id TEXT NOT NULL, price TEXT NOT NULL ) diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 10eb566..a9833f0 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -1,7 +1,7 @@ use crate::model::{ auth::User, - products::Product, permissions::{FinePermission, SecondaryPermission}, + products::{Product, ProductPrice}, Error, Result, }; use crate::{auto_method, DataManager}; @@ -19,7 +19,6 @@ impl DataManager { likes: get!(x->5(i32)) as isize, dislikes: get!(x->6(i32)) as isize, product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - stripe_id: get!(x->8(String)), price: serde_json::from_str(&get!(x->9(String))).unwrap(), } } @@ -85,7 +84,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", params![ &(data.id as i64), &(data.created as i64), @@ -95,7 +94,6 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.product_type).unwrap(), - &data.stripe_id, &serde_json::to_string(&data.price).unwrap(), ] ); @@ -135,4 +133,8 @@ impl DataManager { self.0.1.remove(format!("atto.product:{}", id)).await; Ok(()) } + + auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $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(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 07a23c3..72884ae 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,6 +74,8 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -98,6 +100,8 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, + /// Create products on behalf of the user. + UserCreateProducts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -138,6 +142,8 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, + /// 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/products.rs b/crates/core/src/model/products.rs index 1b54ba3..5e28b76 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; @@ -11,14 +13,13 @@ pub struct Product { pub likes: isize, pub dislikes: isize, pub product_type: ProductType, - pub stripe_id: String, pub price: ProductPrice, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProductType { /// Text + images. - Message, + Data, /// When a commission product is purchased, the creator will receive a request /// prompting them to respond with text + images. /// @@ -26,12 +27,39 @@ pub enum ProductType { /// customer, as seller input is required. /// /// If the request is deleted, the purchase should be immediately refunded. + /// + /// Commissions are paid beforehand to prevent theft. This means it is vital + /// that refunds are enforced. Commission, } +/// A currency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Currency { + USD, + EUR, + GBP, +} + +impl Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Currency::USD => "$", + Currency::EUR => "€", + Currency::GBP => "£", + }) + } +} + /// Price in USD. `(dollars, cents)`. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProductPrice(u64, u64); +pub struct ProductPrice(u64, u64, Currency); + +impl Display for ProductPrice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{}{}.{}", self.2, self.0, self.1)) + } +} impl Product { /// Create a new [`Product`]. @@ -51,7 +79,6 @@ impl Product { likes: 0, dislikes: 0, product_type: r#type, - stripe_id: String::new(), price, } }