Sync cart

This commit is contained in:
eraden 2022-05-19 07:47:47 +02:00
parent a84fda2382
commit b1b4d083b7
11 changed files with 300 additions and 92 deletions

View File

@ -1,3 +1,7 @@
#![feature(drain_filter)]
use std::collections::HashSet;
use database_manager::{query_db, Database}; use database_manager::{query_db, Database};
use model::{ use model::{
AccountId, ProductId, Quantity, QuantityUnit, ShoppingCartId, ShoppingCartItem, AccountId, ProductId, Quantity, QuantityUnit, ShoppingCartId, ShoppingCartItem,
@ -60,8 +64,10 @@ pub enum Error {
ShoppingCartFailed, ShoppingCartFailed,
#[error("Shopping cart is not available for unknown reason")] #[error("Shopping cart is not available for unknown reason")]
CartNotAvailable, CartNotAvailable,
#[error("Failed to add item to cart")] #[error("Failed to modify item to cart")]
CantAddItem, CantModifyItem,
#[error("Failed to modify cart")]
CantModifyCart,
#[error("{0}")] #[error("{0}")]
Db(#[from] database_manager::Error), Db(#[from] database_manager::Error),
#[error("Unable to update cart item")] #[error("Unable to update cart item")]
@ -89,7 +95,7 @@ impl CartManager {
} }
#[derive(actix::Message)] #[derive(actix::Message)]
#[rtype(result = "Result<ShoppingCartItem>")] #[rtype(result = "Result<Option<ShoppingCartItem>>")]
pub struct ModifyItem { pub struct ModifyItem {
pub buyer_id: AccountId, pub buyer_id: AccountId,
pub product_id: ProductId, pub product_id: ProductId,
@ -97,9 +103,12 @@ pub struct ModifyItem {
pub quantity_unit: QuantityUnit, pub quantity_unit: QuantityUnit,
} }
cart_async_handler!(ModifyItem, modify_item, ShoppingCartItem); cart_async_handler!(ModifyItem, modify_item, Option<ShoppingCartItem>);
async fn modify_item(msg: ModifyItem, db: actix::Addr<Database>) -> Result<ShoppingCartItem> { async fn modify_item(
msg: ModifyItem,
db: actix::Addr<Database>,
) -> Result<Option<ShoppingCartItem>> {
let _cart = query_db!( let _cart = query_db!(
db, db,
database_manager::EnsureActiveShoppingCart { database_manager::EnsureActiveShoppingCart {
@ -127,11 +136,17 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr<Database>) -> Result<Shopp
database_manager::ActiveCartItemByProduct { database_manager::ActiveCartItemByProduct {
product_id: msg.product_id product_id: msg.product_id
}, },
Error::CantAddItem Error::CantModifyItem
); );
match item { match item {
Some(item) => Ok(query_db!( Some(item) if **item.quantity == 0 => Ok(query_db!(
db,
database_manager::DeleteShoppingCartItem { id: item.id },
passthrough Error::Db,
Error::CantModifyItem
)),
Some(item) => Ok(Some(query_db!(
db, db,
database_manager::UpdateShoppingCartItem { database_manager::UpdateShoppingCartItem {
id: item.id, id: item.id,
@ -141,9 +156,9 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr<Database>) -> Result<Shopp
quantity_unit: msg.quantity_unit, quantity_unit: msg.quantity_unit,
}, },
passthrough Error::Db, passthrough Error::Db,
Error::CantAddItem Error::CantModifyItem
)), ))),
None => Ok(query_db!( None => Ok(Some(query_db!(
db, db,
database_manager::CreateShoppingCartItem { database_manager::CreateShoppingCartItem {
product_id: msg.product_id, product_id: msg.product_id,
@ -152,8 +167,8 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr<Database>) -> Result<Shopp
quantity_unit: msg.quantity_unit, quantity_unit: msg.quantity_unit,
}, },
passthrough Error::Db, passthrough Error::Db,
Error::CantAddItem Error::CantModifyItem
)), ))),
} }
} }
@ -170,68 +185,101 @@ pub(crate) async fn remove_product(
msg: RemoveProduct, msg: RemoveProduct,
db: actix::Addr<Database>, db: actix::Addr<Database>,
) -> Result<Option<ShoppingCartItem>> { ) -> Result<Option<ShoppingCartItem>> {
match db Ok(query_db!(
.send(database_manager::RemoveCartItem { db,
database_manager::RemoveCartItem {
shopping_cart_id: msg.shopping_cart_id, shopping_cart_id: msg.shopping_cart_id,
shopping_cart_item_id: Some(msg.shopping_cart_item_id), shopping_cart_item_id: Some(msg.shopping_cart_item_id),
product_id: None, product_id: None,
}) },
.await Error::UpdateFailed
{ ))
Ok(Ok(row)) => Ok(row),
Ok(Err(db_err)) => {
log::error!("{db_err}");
Err(Error::UpdateFailed)
}
Err(act_err) => {
log::error!("{act_err:?}");
Err(Error::UpdateFailed)
}
}
} }
#[derive(actix::Message)] #[derive(actix::Message)]
#[rtype(result = "Result<Option<ShoppingCartItem>>")] #[rtype(result = "Result<Vec<ShoppingCartItem>>")]
pub struct ChangeQuantity { pub struct ModifyCart {
pub shopping_cart_id: ShoppingCartId, pub buyer_id: AccountId,
pub shopping_cart_item_id: ShoppingCartItemId, pub items: Vec<ModifyItem>,
pub quantity: Quantity,
pub quantity_unit: QuantityUnit,
} }
cart_async_handler!(ChangeQuantity, change_quantity, Option<ShoppingCartItem>); cart_async_handler!(ModifyCart, modify_cart, Vec<ShoppingCartItem>);
pub(crate) async fn change_quantity( async fn modify_cart(msg: ModifyCart, db: actix::Addr<Database>) -> Result<Vec<ShoppingCartItem>> {
msg: ChangeQuantity, let _cart = query_db!(
db: actix::Addr<Database>,
) -> Result<Option<ShoppingCartItem>> {
if **msg.quantity == 0 {
return remove_product(
RemoveProduct {
shopping_cart_id: msg.shopping_cart_id,
shopping_cart_item_id: msg.shopping_cart_item_id,
},
db,
)
.await;
}
let item: ShoppingCartItem = query_db!(
db, db,
database_manager::FindShoppingCartItem { database_manager::EnsureActiveShoppingCart {
id: msg.shopping_cart_item_id, buyer_id: msg.buyer_id,
}, },
Error::NotExists(msg.shopping_cart_item_id) Error::ShoppingCartFailed
);
let mut carts: Vec<model::ShoppingCart> = query_db!(
db,
database_manager::AccountShoppingCarts {
account_id: msg.buyer_id,
state: Some(ShoppingCartState::Active),
},
passthrough Error::Db,
Error::CartNotAvailable
);
let cart = if carts.is_empty() {
return Err(Error::CartNotAvailable);
} else {
carts.remove(0)
};
let existing =
msg.items
.iter()
.fold(HashSet::with_capacity(msg.items.len()), |mut agg, item| {
agg.insert(item.product_id);
agg
});
let mut items: Vec<model::ShoppingCartItem> = query_db!(
db,
database_manager::CartItems {
shopping_cart_id: cart.id
},
Error::CantModifyCart
); );
Ok(Some(query_db!( for item in items.drain_filter(|item| !existing.contains(&item.product_id)) {
db, query_db!(
database_manager::UpdateShoppingCartItem { db,
id: msg.shopping_cart_item_id, database_manager::RemoveCartItem {
product_id: item.product_id, shopping_cart_id: cart.id,
shopping_cart_id: item.shopping_cart_id, shopping_cart_item_id: Some(item.id),
quantity: msg.quantity, product_id: None,
quantity_unit: msg.quantity_unit, },
}, Error::CantModifyCart
Error::ChangeQuantity );
))) }
let mut out = Vec::with_capacity(items.len());
for ShoppingCartItem {
id: _,
product_id,
shopping_cart_id: _,
quantity,
quantity_unit,
} in items
{
if let Some(item) = modify_item(
ModifyItem {
buyer_id: msg.buyer_id,
product_id,
quantity,
quantity_unit,
},
db.clone(),
)
.await?
{
out.push(item);
}
}
Ok(out)
} }

View File

@ -190,6 +190,38 @@ RETURNING id, product_id, shopping_cart_id, quantity, quantity_unit
}) })
} }
#[derive(actix::Message)]
#[rtype(result = "Result<Option<ShoppingCartItem>>")]
pub struct DeleteShoppingCartItem {
pub id: ShoppingCartItemId,
}
db_async_handler!(
DeleteShoppingCartItem,
delete_shopping_cart_item,
Option<ShoppingCartItem>
);
pub(crate) async fn delete_shopping_cart_item(
msg: DeleteShoppingCartItem,
db: PgPool,
) -> Result<Option<ShoppingCartItem>> {
sqlx::query_as(
r#"
DELETE FROM shopping_cart_items
WHERE id = $1
RETURNING id, product_id, shopping_cart_id, quantity, quantity_unit
"#,
)
.bind(msg.id)
.fetch_optional(&db)
.await
.map_err(|e| {
log::error!("{e:?}");
super::Error::ShoppingCartItem(Error::CantUpdate(msg.id))
})
}
#[derive(actix::Message)] #[derive(actix::Message)]
#[rtype(result = "Result<ShoppingCartItem>")] #[rtype(result = "Result<ShoppingCartItem>")]
pub struct FindShoppingCartItem { pub struct FindShoppingCartItem {

View File

@ -110,7 +110,7 @@ impl Responder for Error {
}), }),
}, },
Error::Public(PublicError::ApiV1( Error::Public(PublicError::ApiV1(
V1Error::AddItem | V1Error::RemoveItem | V1Error::AddOrder, V1Error::ModifyItem | V1Error::RemoveItem | V1Error::AddOrder,
)) => HttpResponse::BadRequest() )) => HttpResponse::BadRequest()
.content_type("application/json") .content_type("application/json")
.json(Failure { .json(Failure {

View File

@ -17,7 +17,7 @@ pub enum Error {
#[error("Failed to remove shopping cart item")] #[error("Failed to remove shopping cart item")]
RemoveItem, RemoveItem,
#[error("Failed to add shopping cart item")] #[error("Failed to add shopping cart item")]
AddItem, ModifyItem,
#[error("Failed to create order")] #[error("Failed to create order")]
AddOrder, AddOrder,

View File

@ -93,11 +93,11 @@ async fn update_cart_item(
cart: Data<Addr<CartManager>>, cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>, tm: Data<Addr<TokenManager>>,
credentials: BearerAuth, credentials: BearerAuth,
Json(payload): Json<api::CreateItemInput>, Json(payload): Json<api::UpdateItemInput>,
) -> Result<Json<api::CreateItemOutput>> { ) -> Result<Json<api::UpdateItemOutput>> {
let token = credentials.require_user(tm.into_inner()).await?; let token = credentials.require_user(tm.into_inner()).await?;
let item: model::ShoppingCartItem = query_cart!( let item: Option<model::ShoppingCartItem> = query_cart!(
cart, cart,
cart_manager::ModifyItem { cart_manager::ModifyItem {
buyer_id: token.account_id(), buyer_id: token.account_id(),
@ -105,13 +105,55 @@ async fn update_cart_item(
quantity: payload.quantity, quantity: payload.quantity,
quantity_unit: payload.quantity_unit, quantity_unit: payload.quantity_unit,
}, },
routes::Error::Public(super::Error::AddItem.into()), routes::Error::Public(super::Error::ModifyItem.into()),
routes::Error::Public(PublicError::DatabaseConnection) routes::Error::Public(PublicError::DatabaseConnection)
); );
Ok(Json(api::CreateItemOutput { match item {
success: true, Some(item) => Ok(Json(api::UpdateItemOutput {
shopping_cart_item: item.into(), shopping_cart_item: item.into(),
})),
None => Err(routes::Error::Public(super::Error::ModifyItem.into())),
}
}
#[put("/shopping-cart")]
async fn update_cart(
cart: Data<Addr<CartManager>>,
tm: Data<Addr<TokenManager>>,
credentials: BearerAuth,
Json(payload): Json<api::UpdateCartInput>,
) -> Result<Json<api::UpdateCartOutput>> {
let token = credentials.require_user(tm.into_inner()).await?;
let items = payload
.items
.into_iter()
.map(
|model::api::UpdateItemInput {
product_id,
quantity,
quantity_unit,
}| cart_manager::ModifyItem {
buyer_id: token.account_id(),
product_id,
quantity,
quantity_unit,
},
)
.collect();
let items: Vec<model::ShoppingCartItem> = query_cart!(
cart,
cart_manager::ModifyCart {
buyer_id: token.account_id(),
items
},
routes::Error::Public(super::Error::ModifyItem.into()),
routes::Error::Public(PublicError::DatabaseConnection)
);
Ok(Json(api::UpdateCartOutput {
items: items.into_iter().map(Into::into).collect(),
})) }))
} }
@ -229,6 +271,8 @@ pub(crate) fn configure(config: &mut ServiceConfig) {
.service(shopping_cart) .service(shopping_cart)
.service(delete_cart_item) .service(delete_cart_item)
.service(create_order) .service(create_order)
.service(update_cart)
.service(update_cart_item)
.service(me) .service(me)
.service(verify_token) .service(verify_token)
.service(refresh_token); .service(refresh_token);

View File

@ -366,18 +366,27 @@ pub struct DeleteItemOutput {
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct CreateItemInput { pub struct UpdateItemInput {
pub product_id: ProductId, pub product_id: ProductId,
pub quantity: Quantity, pub quantity: Quantity,
pub quantity_unit: QuantityUnit, pub quantity_unit: QuantityUnit,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct CreateItemOutput { pub struct UpdateItemOutput {
pub success: bool,
pub shopping_cart_item: ShoppingCartItem, pub shopping_cart_item: ShoppingCartItem,
} }
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateCartInput {
pub items: Vec<UpdateItemInput>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateCartOutput {
pub items: Vec<ShoppingCartItem>,
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct SearchRequest { pub struct SearchRequest {
/// Match string /// Match string

View File

@ -2,6 +2,7 @@ use model::{AccessTokenString, RefreshTokenString};
use seed::fetch::{Header, Method, Request}; use seed::fetch::{Header, Method, Request};
use crate::api::perform; use crate::api::perform;
use crate::NetRes;
pub async fn config() -> super::NetRes<model::api::Config> { pub async fn config() -> super::NetRes<model::api::Config> {
perform(Request::new("/config").method(Method::Get)).await perform(Request::new("/config").method(Method::Get)).await
@ -65,3 +66,54 @@ pub async fn sign_up(
) )
.await .await
} }
pub async fn update_cart_item(
access_token: &AccessTokenString,
product_id: model::ProductId,
quantity: model::Quantity,
quantity_unit: model::QuantityUnit,
) -> NetRes<model::api::UpdateItemOutput> {
let input = model::api::UpdateItemInput {
product_id,
quantity,
quantity_unit,
};
perform(
Request::new("/api/v1/shopping-cart-item")
.method(Method::Put)
.header(Header::bearer(access_token.as_str()))
.json(&input)
.map_err(crate::api::NetRes::Http)?,
)
.await
}
pub async fn update_cart(
access_token: AccessTokenString,
items: Vec<crate::shopping_cart::Item>,
) -> NetRes<model::api::UpdateCartOutput> {
let input = model::api::UpdateCartInput {
items: items
.into_iter()
.map(
|crate::shopping_cart::Item {
product_id,
quantity,
quantity_unit,
}| model::api::UpdateItemInput {
product_id,
quantity,
quantity_unit,
},
)
.collect(),
};
perform(
Request::new("/api/v1/shopping-cart")
.method(Method::Put)
.header(Header::bearer(access_token.as_str()))
.json(&input)
.map_err(crate::api::NetRes::Http)?,
)
.await
}

View File

@ -169,13 +169,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
let page = fetch_page!(public model, SignIn); let page = fetch_page!(public model, SignIn);
pages::public::sign_in::update(msg, page, &mut orders.proxy(Into::into)) pages::public::sign_in::update(msg, page, &mut orders.proxy(Into::into))
} }
Msg::Public(pages::public::PublicMsg::SignUp(
pages::public::sign_up::RegisterMsg::AccountCreated(res),
)) => {
orders
.skip()
.send_msg(Msg::Session(SessionMsg::TokenRefreshed(res)));
}
Msg::Public(pages::public::PublicMsg::SignUp(msg)) => { Msg::Public(pages::public::PublicMsg::SignUp(msg)) => {
let page = fetch_page!(public model, SignUp); let page = fetch_page!(public model, SignUp);
pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into)) pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into))

View File

@ -62,7 +62,6 @@ pub fn view(model: &crate::Model, page: &SignInPage) -> Node<crate::Msg> {
] ]
.map_msg(Into::into); .map_msg(Into::into);
// crate::shared::view::public_navbar(model),
div![super::layout::view(model, content, None)] div![super::layout::view(model, content, None)]
} }

View File

@ -1,10 +1,11 @@
use std::str::FromStr; use std::str::FromStr;
use model::{Email, Login, Password, PasswordConfirmation};
use seed::prelude::*; use seed::prelude::*;
use seed::*; use seed::*;
use crate::pages::Urls; use crate::pages::Urls;
use crate::shopping_cart::CartMsg;
use crate::SessionMsg;
#[derive(Debug)] #[derive(Debug)]
pub enum RegisterMsg { pub enum RegisterMsg {
@ -38,18 +39,18 @@ pub fn page_changed(_url: Url, _model: &mut SignUpPage) {}
pub fn update(msg: RegisterMsg, model: &mut SignUpPage, orders: &mut impl Orders<crate::Msg>) { pub fn update(msg: RegisterMsg, model: &mut SignUpPage, orders: &mut impl Orders<crate::Msg>) {
match msg { match msg {
RegisterMsg::LoginChanged(value) => { RegisterMsg::LoginChanged(value) => {
model.login = Login::new(value); model.login = model::Login::new(value);
} }
RegisterMsg::EmailChanged(value) => { RegisterMsg::EmailChanged(value) => {
if let Ok(email) = Email::from_str(&value) { if let Ok(email) = model::Email::from_str(&value) {
model.email = email; model.email = email;
} }
} }
RegisterMsg::PasswordChanged(value) => { RegisterMsg::PasswordChanged(value) => {
model.password = Password::new(value); model.password = model::Password::new(value);
} }
RegisterMsg::PasswordConfirmationChanged(value) => { RegisterMsg::PasswordConfirmationChanged(value) => {
model.password_confirmation = PasswordConfirmation::new(value); model.password_confirmation = model::PasswordConfirmation::new(value);
} }
RegisterMsg::Submit => { RegisterMsg::Submit => {
let email = model.email.clone(); let email = model.email.clone();
@ -72,7 +73,12 @@ pub fn update(msg: RegisterMsg, model: &mut SignUpPage, orders: &mut impl Orders
) )
}); });
} }
RegisterMsg::AccountCreated(_) => {} RegisterMsg::AccountCreated(res) => {
orders
.skip()
.send_msg(crate::Msg::Session(SessionMsg::TokenRefreshed(res)))
.send_msg(crate::Msg::Cart(CartMsg::Sync));
}
} }
} }

View File

@ -2,7 +2,8 @@ use model::{ProductId, Quantity, QuantityUnit};
use seed::prelude::*; use seed::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{Model, Msg}; use crate::shared::notification::NotificationMsg;
use crate::{Model, Msg, NetRes};
#[derive(Debug)] #[derive(Debug)]
pub enum CartMsg { pub enum CartMsg {
@ -19,6 +20,9 @@ pub enum CartMsg {
Remove(ProductId), Remove(ProductId),
Hover, Hover,
Leave, Leave,
/// Send current non-empty cart to server
Sync,
SyncResult(NetRes<model::api::UpdateCartOutput>),
} }
impl From<CartMsg> for Msg { impl From<CartMsg> for Msg {
@ -29,7 +33,7 @@ impl From<CartMsg> for Msg {
pub type Items = indexmap::IndexMap<ProductId, Item>; pub type Items = indexmap::IndexMap<ProductId, Item>;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, Serialize, Deserialize)]
pub struct Item { pub struct Item {
pub product_id: ProductId, pub product_id: ProductId,
pub quantity: Quantity, pub quantity: Quantity,
@ -48,7 +52,7 @@ pub fn init(model: &mut Model, _orders: &mut impl Orders<Msg>) {
model.cart = load_local(); model.cart = load_local();
} }
pub fn update(msg: CartMsg, model: &mut Model, _orders: &mut impl Orders<Msg>) { pub fn update(msg: CartMsg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
CartMsg::AddItem { CartMsg::AddItem {
quantity, quantity,
@ -96,6 +100,27 @@ pub fn update(msg: CartMsg, model: &mut Model, _orders: &mut impl Orders<Msg>) {
CartMsg::Leave => { CartMsg::Leave => {
model.cart.hover = false; model.cart.hover = false;
} }
CartMsg::Sync => {
if let Some(access_token) = model.shared.access_token.as_ref().cloned() {
let items: Vec<Item> = model.cart.items.values().map(Clone::clone).collect();
orders.perform_cmd(async {
crate::Msg::from(CartMsg::SyncResult(
crate::api::public::update_cart(access_token, items).await,
))
});
}
}
CartMsg::SyncResult(NetRes::Success(cart)) => {
// cart.items
}
CartMsg::SyncResult(NetRes::Error(failure)) => {
for msg in failure.errors {
orders.send_msg(NotificationMsg::Error(msg).into());
}
}
CartMsg::SyncResult(NetRes::Http(_cart)) => {
orders.send_msg(NotificationMsg::Error("Unable to sync cart".into()).into());
}
} }
} }