diff --git a/actors/cart_manager/src/lib.rs b/actors/cart_manager/src/lib.rs index 64ba6fe..b4bb1e4 100644 --- a/actors/cart_manager/src/lib.rs +++ b/actors/cart_manager/src/lib.rs @@ -1,3 +1,7 @@ +#![feature(drain_filter)] + +use std::collections::HashSet; + use database_manager::{query_db, Database}; use model::{ AccountId, ProductId, Quantity, QuantityUnit, ShoppingCartId, ShoppingCartItem, @@ -60,8 +64,10 @@ pub enum Error { ShoppingCartFailed, #[error("Shopping cart is not available for unknown reason")] CartNotAvailable, - #[error("Failed to add item to cart")] - CantAddItem, + #[error("Failed to modify item to cart")] + CantModifyItem, + #[error("Failed to modify cart")] + CantModifyCart, #[error("{0}")] Db(#[from] database_manager::Error), #[error("Unable to update cart item")] @@ -89,7 +95,7 @@ impl CartManager { } #[derive(actix::Message)] -#[rtype(result = "Result")] +#[rtype(result = "Result>")] pub struct ModifyItem { pub buyer_id: AccountId, pub product_id: ProductId, @@ -97,9 +103,12 @@ pub struct ModifyItem { pub quantity_unit: QuantityUnit, } -cart_async_handler!(ModifyItem, modify_item, ShoppingCartItem); +cart_async_handler!(ModifyItem, modify_item, Option); -async fn modify_item(msg: ModifyItem, db: actix::Addr) -> Result { +async fn modify_item( + msg: ModifyItem, + db: actix::Addr, +) -> Result> { let _cart = query_db!( db, database_manager::EnsureActiveShoppingCart { @@ -127,11 +136,17 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr) -> Result 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, database_manager::UpdateShoppingCartItem { id: item.id, @@ -141,9 +156,9 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr) -> Result Ok(query_db!( + Error::CantModifyItem + ))), + None => Ok(Some(query_db!( db, database_manager::CreateShoppingCartItem { product_id: msg.product_id, @@ -152,8 +167,8 @@ async fn modify_item(msg: ModifyItem, db: actix::Addr) -> Result, ) -> Result> { - match db - .send(database_manager::RemoveCartItem { + Ok(query_db!( + db, + database_manager::RemoveCartItem { shopping_cart_id: msg.shopping_cart_id, shopping_cart_item_id: Some(msg.shopping_cart_item_id), product_id: None, - }) - .await - { - 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) - } - } + }, + Error::UpdateFailed + )) } #[derive(actix::Message)] -#[rtype(result = "Result>")] -pub struct ChangeQuantity { - pub shopping_cart_id: ShoppingCartId, - pub shopping_cart_item_id: ShoppingCartItemId, - pub quantity: Quantity, - pub quantity_unit: QuantityUnit, +#[rtype(result = "Result>")] +pub struct ModifyCart { + pub buyer_id: AccountId, + pub items: Vec, } -cart_async_handler!(ChangeQuantity, change_quantity, Option); +cart_async_handler!(ModifyCart, modify_cart, Vec); -pub(crate) async fn change_quantity( - msg: ChangeQuantity, - db: actix::Addr, -) -> Result> { - 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!( +async fn modify_cart(msg: ModifyCart, db: actix::Addr) -> Result> { + let _cart = query_db!( db, - database_manager::FindShoppingCartItem { - id: msg.shopping_cart_item_id, + database_manager::EnsureActiveShoppingCart { + buyer_id: msg.buyer_id, }, - Error::NotExists(msg.shopping_cart_item_id) + Error::ShoppingCartFailed + ); + let mut carts: Vec = 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 = query_db!( + db, + database_manager::CartItems { + shopping_cart_id: cart.id + }, + Error::CantModifyCart ); - Ok(Some(query_db!( - db, - database_manager::UpdateShoppingCartItem { - id: msg.shopping_cart_item_id, - product_id: item.product_id, - shopping_cart_id: item.shopping_cart_id, - quantity: msg.quantity, - quantity_unit: msg.quantity_unit, - }, - Error::ChangeQuantity - ))) + for item in items.drain_filter(|item| !existing.contains(&item.product_id)) { + query_db!( + db, + database_manager::RemoveCartItem { + shopping_cart_id: cart.id, + shopping_cart_item_id: Some(item.id), + product_id: None, + }, + Error::CantModifyCart + ); + } + + 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) } diff --git a/actors/database_manager/src/shopping_cart_items.rs b/actors/database_manager/src/shopping_cart_items.rs index d8b6377..2b4b3bd 100644 --- a/actors/database_manager/src/shopping_cart_items.rs +++ b/actors/database_manager/src/shopping_cart_items.rs @@ -190,6 +190,38 @@ RETURNING id, product_id, shopping_cart_id, quantity, quantity_unit }) } +#[derive(actix::Message)] +#[rtype(result = "Result>")] +pub struct DeleteShoppingCartItem { + pub id: ShoppingCartItemId, +} + +db_async_handler!( + DeleteShoppingCartItem, + delete_shopping_cart_item, + Option +); + +pub(crate) async fn delete_shopping_cart_item( + msg: DeleteShoppingCartItem, + db: PgPool, +) -> Result> { + 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)] #[rtype(result = "Result")] pub struct FindShoppingCartItem { diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 4509051..b11b41d 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -110,7 +110,7 @@ impl Responder for Error { }), }, Error::Public(PublicError::ApiV1( - V1Error::AddItem | V1Error::RemoveItem | V1Error::AddOrder, + V1Error::ModifyItem | V1Error::RemoveItem | V1Error::AddOrder, )) => HttpResponse::BadRequest() .content_type("application/json") .json(Failure { diff --git a/api/src/routes/public/api_v1.rs b/api/src/routes/public/api_v1.rs index 0e1c990..d8b338b 100644 --- a/api/src/routes/public/api_v1.rs +++ b/api/src/routes/public/api_v1.rs @@ -17,7 +17,7 @@ pub enum Error { #[error("Failed to remove shopping cart item")] RemoveItem, #[error("Failed to add shopping cart item")] - AddItem, + ModifyItem, #[error("Failed to create order")] AddOrder, diff --git a/api/src/routes/public/api_v1/restricted.rs b/api/src/routes/public/api_v1/restricted.rs index 376fbc2..31715fa 100644 --- a/api/src/routes/public/api_v1/restricted.rs +++ b/api/src/routes/public/api_v1/restricted.rs @@ -93,11 +93,11 @@ async fn update_cart_item( cart: Data>, tm: Data>, credentials: BearerAuth, - Json(payload): Json, -) -> Result> { + Json(payload): Json, +) -> Result> { let token = credentials.require_user(tm.into_inner()).await?; - let item: model::ShoppingCartItem = query_cart!( + let item: Option = query_cart!( cart, cart_manager::ModifyItem { buyer_id: token.account_id(), @@ -105,13 +105,55 @@ async fn update_cart_item( quantity: payload.quantity, quantity_unit: payload.quantity_unit, }, - routes::Error::Public(super::Error::AddItem.into()), + routes::Error::Public(super::Error::ModifyItem.into()), routes::Error::Public(PublicError::DatabaseConnection) ); - Ok(Json(api::CreateItemOutput { - success: true, - shopping_cart_item: item.into(), + match item { + Some(item) => Ok(Json(api::UpdateItemOutput { + shopping_cart_item: item.into(), + })), + None => Err(routes::Error::Public(super::Error::ModifyItem.into())), + } +} + +#[put("/shopping-cart")] +async fn update_cart( + cart: Data>, + tm: Data>, + credentials: BearerAuth, + Json(payload): Json, +) -> Result> { + 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 = 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(delete_cart_item) .service(create_order) + .service(update_cart) + .service(update_cart_item) .service(me) .service(verify_token) .service(refresh_token); diff --git a/shared/model/src/api.rs b/shared/model/src/api.rs index a8422e8..443cf56 100644 --- a/shared/model/src/api.rs +++ b/shared/model/src/api.rs @@ -366,18 +366,27 @@ pub struct DeleteItemOutput { } #[derive(Serialize, Deserialize, Debug)] -pub struct CreateItemInput { +pub struct UpdateItemInput { pub product_id: ProductId, pub quantity: Quantity, pub quantity_unit: QuantityUnit, } #[derive(Serialize, Deserialize, Debug)] -pub struct CreateItemOutput { - pub success: bool, +pub struct UpdateItemOutput { pub shopping_cart_item: ShoppingCartItem, } +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateCartInput { + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UpdateCartOutput { + pub items: Vec, +} + #[derive(Serialize, Deserialize, Debug)] pub struct SearchRequest { /// Match string diff --git a/web/src/api/public.rs b/web/src/api/public.rs index bd2bebc..15fd33c 100644 --- a/web/src/api/public.rs +++ b/web/src/api/public.rs @@ -2,6 +2,7 @@ use model::{AccessTokenString, RefreshTokenString}; use seed::fetch::{Header, Method, Request}; use crate::api::perform; +use crate::NetRes; pub async fn config() -> super::NetRes { perform(Request::new("/config").method(Method::Get)).await @@ -65,3 +66,54 @@ pub async fn sign_up( ) .await } + +pub async fn update_cart_item( + access_token: &AccessTokenString, + product_id: model::ProductId, + quantity: model::Quantity, + quantity_unit: model::QuantityUnit, +) -> NetRes { + 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, +) -> NetRes { + 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 +} diff --git a/web/src/lib.rs b/web/src/lib.rs index eeb4cd7..258e06b 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -169,13 +169,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { let page = fetch_page!(public model, SignIn); 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)) => { let page = fetch_page!(public model, SignUp); pages::public::sign_up::update(msg, page, &mut orders.proxy(Into::into)) diff --git a/web/src/pages/public/sign_in.rs b/web/src/pages/public/sign_in.rs index ac576af..5167e23 100644 --- a/web/src/pages/public/sign_in.rs +++ b/web/src/pages/public/sign_in.rs @@ -62,7 +62,6 @@ pub fn view(model: &crate::Model, page: &SignInPage) -> Node { ] .map_msg(Into::into); - // crate::shared::view::public_navbar(model), div![super::layout::view(model, content, None)] } diff --git a/web/src/pages/public/sign_up.rs b/web/src/pages/public/sign_up.rs index 87cf44d..d9040a9 100644 --- a/web/src/pages/public/sign_up.rs +++ b/web/src/pages/public/sign_up.rs @@ -1,10 +1,11 @@ use std::str::FromStr; -use model::{Email, Login, Password, PasswordConfirmation}; use seed::prelude::*; use seed::*; use crate::pages::Urls; +use crate::shopping_cart::CartMsg; +use crate::SessionMsg; #[derive(Debug)] 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) { match msg { RegisterMsg::LoginChanged(value) => { - model.login = Login::new(value); + model.login = model::Login::new(value); } RegisterMsg::EmailChanged(value) => { - if let Ok(email) = Email::from_str(&value) { + if let Ok(email) = model::Email::from_str(&value) { model.email = email; } } RegisterMsg::PasswordChanged(value) => { - model.password = Password::new(value); + model.password = model::Password::new(value); } RegisterMsg::PasswordConfirmationChanged(value) => { - model.password_confirmation = PasswordConfirmation::new(value); + model.password_confirmation = model::PasswordConfirmation::new(value); } RegisterMsg::Submit => { 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)); + } } } diff --git a/web/src/shopping_cart.rs b/web/src/shopping_cart.rs index bc859e5..428c882 100644 --- a/web/src/shopping_cart.rs +++ b/web/src/shopping_cart.rs @@ -2,7 +2,8 @@ use model::{ProductId, Quantity, QuantityUnit}; use seed::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{Model, Msg}; +use crate::shared::notification::NotificationMsg; +use crate::{Model, Msg, NetRes}; #[derive(Debug)] pub enum CartMsg { @@ -19,6 +20,9 @@ pub enum CartMsg { Remove(ProductId), Hover, Leave, + /// Send current non-empty cart to server + Sync, + SyncResult(NetRes), } impl From for Msg { @@ -29,7 +33,7 @@ impl From for Msg { pub type Items = indexmap::IndexMap; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] pub struct Item { pub product_id: ProductId, pub quantity: Quantity, @@ -48,7 +52,7 @@ pub fn init(model: &mut Model, _orders: &mut impl Orders) { model.cart = load_local(); } -pub fn update(msg: CartMsg, model: &mut Model, _orders: &mut impl Orders) { +pub fn update(msg: CartMsg, model: &mut Model, orders: &mut impl Orders) { match msg { CartMsg::AddItem { quantity, @@ -96,6 +100,27 @@ pub fn update(msg: CartMsg, model: &mut Model, _orders: &mut impl Orders) { CartMsg::Leave => { model.cart.hover = false; } + CartMsg::Sync => { + if let Some(access_token) = model.shared.access_token.as_ref().cloned() { + let items: Vec = 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()); + } } }