diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 0b63257..0c2ad66 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -52,6 +52,7 @@ mod m20260623_000004_currencies; mod m20260625_000001_add_avatar_to_users; mod m20260627_000001_order_residence_address; mod m20260627_000002_payment_settings; +mod m20260627_000003_account_cart_items; pub struct Migrator; #[async_trait::async_trait] @@ -108,6 +109,7 @@ impl MigratorTrait for Migrator { Box::new(m20260625_000001_add_avatar_to_users::Migration), Box::new(m20260627_000001_order_residence_address::Migration), Box::new(m20260627_000002_payment_settings::Migration), + Box::new(m20260627_000003_account_cart_items::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260627_000003_account_cart_items.rs b/migration/src/m20260627_000003_account_cart_items.rs new file mode 100644 index 0000000..36ffd04 --- /dev/null +++ b/migration/src/m20260627_000003_account_cart_items.rs @@ -0,0 +1,48 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table( + m, + "account_cart_items", + &[ + ("id", ColType::PkAuto), + ("variant_id", ColType::Integer), + ("quantity", ColType::Integer), + ], + &[("user", "")], + ) + .await?; + + m.create_foreign_key( + ForeignKey::create() + .name("fk-account_cart_items-variant_id-to-product_variants") + .from(Alias::new("account_cart_items"), Alias::new("variant_id")) + .to(Alias::new("product_variants"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction) + .to_owned(), + ) + .await?; + + m.create_index( + Index::create() + .name("idx_account_cart_items_user_variant_unique") + .table(Alias::new("account_cart_items")) + .col(Alias::new("user_id")) + .col(Alias::new("variant_id")) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "account_cart_items").await + } +} diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index 17fb300..a649e6a 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -1,10 +1,11 @@ use crate::{ + controllers::cart, models::users::{self, LoginParams, RegisterParams}, views::auth::{CurrentResponse, LoginResponse}, mailers::auth::AuthMailer, shared::guard::is_admin, }; -use axum_extra::extract::cookie::{Cookie, SameSite}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -88,6 +89,7 @@ pub struct ResendVerificationParams { /// welcome email to the user #[debug_handler] async fn register( + jar: CookieJar, State(ctx): State, Json(params): Json, ) -> Result { @@ -109,6 +111,7 @@ async fn register( .into_active_model() .set_email_verification_sent(&ctx.db) .await?; + cart::claim_guest_cart(&ctx, &jar, user.id).await?; AuthMailer::send_welcome(&ctx, &user).await?; @@ -199,8 +202,9 @@ async fn login(State(ctx): State, Json(params): Json) - .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; + let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?; format::render() - .cookies(&[auth_cookie(&token, jwt_secret.expiration)])? + .cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])? .json(LoginResponse::new(&user, &token, is_admin(&ctx, &user))) } @@ -212,7 +216,9 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result Result { - format::render().cookies(&[clear_auth_cookie()])?.json(()) + format::render() + .cookies(&[clear_auth_cookie(), cart::cleared_cart_cookie()])? + .json(()) } /// Magic link authentication provides a secure and passwordless way to log in to the application. @@ -274,8 +280,9 @@ async fn magic_link_verify( .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; + let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?; format::render() - .cookies(&[auth_cookie(&token, jwt_secret.expiration)])? + .cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])? .json(LoginResponse::new(&user, &token, is_admin(&ctx, &user))) } diff --git a/src/controllers/auth_pages.rs b/src/controllers/auth_pages.rs index 8f629b8..05186d4 100644 --- a/src/controllers/auth_pages.rs +++ b/src/controllers/auth_pages.rs @@ -13,6 +13,7 @@ use serde_json::json; use crate::{ controllers::auth as auth_controller, + controllers::cart, controllers::i18n::current_lang, mailers::auth::AuthMailer, models::users::{self, LoginParams, RegisterParams}, @@ -105,9 +106,13 @@ async fn login( let token = user .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; + let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?; format::render() - .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? + .cookies(&[ + auth_controller::auth_cookie(&token, jwt_secret.expiration), + cart_cookie, + ])? .redirect(home_for(&ctx, &user)) } @@ -185,11 +190,13 @@ async fn login_totp( let token = user .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; + let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?; format::render() .cookies(&[ auth_controller::auth_cookie(&token, jwt_secret.expiration), auth_controller::clear_totp_pending_cookie(), + cart_cookie, ])? .redirect(home_for(&ctx, &user)) } @@ -270,6 +277,7 @@ async fn register( .into_active_model() .set_email_verification_sent(&ctx.db) .await?; + cart::claim_guest_cart(&ctx, &jar, user.id).await?; // The account already exists; a failed email send shouldn't 500 the page — // log it and let the user fall back to resend-verification. @@ -304,7 +312,9 @@ async fn verify( }; if user.email_verified_at.is_none() { + let user_id = user.id; user.into_active_model().verified(&ctx.db).await?; + cart::claim_guest_cart(&ctx, &jar, user_id).await?; } verified_view(&v, &jar, true) @@ -446,7 +456,10 @@ async fn set_password( #[debug_handler] async fn logout() -> Result { format::render() - .cookies(&[auth_controller::clear_auth_cookie()])? + .cookies(&[ + auth_controller::clear_auth_cookie(), + cart::cleared_cart_cookie(), + ])? .redirect("/login") } diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs index 343e00e..659d2c5 100644 --- a/src/controllers/cart.rs +++ b/src/controllers/cart.rs @@ -1,4 +1,11 @@ -use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}}; +use crate::{ + controllers::i18n::current_lang, + models::{account_cart_items, product_variants, products, users}, + shared::{ + currency::{self, Currency}, + guard, pricing, + }, +}; use axum::{ http::{HeaderMap, StatusCode}, response::Redirect, @@ -64,6 +71,75 @@ fn cart_cookie(value: String) -> Cookie<'static> { .build() } +pub(crate) fn cleared_cart_cookie() -> Cookie<'static> { + Cookie::build((CART_COOKIE, "")) + .path("/") + .same_site(SameSite::Lax) + .max_age(TimeDuration::seconds(0)) + .build() +} + +fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> { + let mut normalized: Vec<(i32, i32)> = Vec::new(); + for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) { + if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) { + existing.1 += qty; + } else { + normalized.push((id, qty)); + } + } + normalized +} + +async fn stored_cart( + ctx: &AppContext, + user: Option<&users::Model>, + jar: &CookieJar, +) -> Result> { + match user { + Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?), + None => Ok(normalize_items(parse_cart(jar))), + } +} + +async fn persist_cart( + ctx: &AppContext, + jar: CookieJar, + user: Option<&users::Model>, + items: &[(i32, i32)], +) -> Result { + let items = normalize_items(items.to_vec()); + if let Some(user) = user { + account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?; + } + Ok(jar.add(cart_cookie(serialize_cart(&items)))) +} + +pub(crate) async fn claim_guest_cart( + ctx: &AppContext, + jar: &CookieJar, + user_id: i32, +) -> Result<()> { + let items = normalize_items(parse_cart(jar)); + if !items.is_empty() { + account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?; + } + Ok(()) +} + +pub(crate) async fn cart_cookie_for_user( + ctx: &AppContext, + user_id: i32, +) -> Result> { + let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?; + Ok(cart_cookie(serialize_cart(&items))) +} + +pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> { + account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?; + Ok(()) +} + /// Look up a variant whose product is published, returning the variant together /// with its parent product (for name/slug). async fn published_variant( @@ -94,7 +170,8 @@ async fn add( return Err(Error::NotFound); }; - let mut items = parse_cart(&jar); + let user = guard::current_user(&ctx, &jar).await; + let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?; let add_qty = form.quantity.unwrap_or(1).max(1); if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) { entry.1 = variant.cap(entry.1 + add_qty); @@ -103,7 +180,7 @@ async fn add( } items.retain(|(_, qty)| *qty > 0); - let jar = jar.add(cart_cookie(serialize_cart(&items))); + let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?; // Adding to the cart should never navigate away: htmx requests get an empty // 204 (the header cart badge updates client-side), and a no-JS submit goes @@ -135,13 +212,14 @@ async fn update( None => 0, }; - let mut items = parse_cart(&jar); + let user = guard::current_user(&ctx, &jar).await; + let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?; if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) { entry.1 = clamped; } items.retain(|(_, qty)| *qty > 0); - let jar = jar.add(cart_cookie(serialize_cart(&items))); + let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?; cart_response(&ctx, &v, jar, &headers).await } @@ -153,10 +231,11 @@ async fn remove( headers: HeaderMap, Form(form): Form, ) -> Result { - let mut items = parse_cart(&jar); + let user = guard::current_user(&ctx, &jar).await; + let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?; items.retain(|(id, _)| *id != form.variant_id); - let jar = jar.add(cart_cookie(serialize_cart(&items))); + let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?; cart_response(&ctx, &v, jar, &headers).await } @@ -176,7 +255,8 @@ async fn cart_response( let cur = currency::resolve(ctx, &jar).await; let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?; // Persist the re-validated cookie (drops now-invalid lines). - let jar = jar.add(cart_cookie(serialize_cart(&valid))); + let user = guard::current_user(ctx, &jar).await; + let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?; let response = format::view( v, "shop/_cart_body.html", @@ -190,9 +270,9 @@ async fn cart_response( Ok((jar, response).into_response()) } -/// Resolve the cart cookie into priced line items, dropping anything that is no -/// longer purchasable and clamping quantities to current stock. Returns the -/// (re-validated) lines, the rebuilt cookie value, and the total in cents. +/// Resolve the active cart into priced line items, dropping anything that is no +/// longer purchasable and clamping quantities to current stock. Guests resolve +/// from the cookie; authenticated users resolve from their account cart. pub(crate) async fn resolve_cart( ctx: &AppContext, jar: &CookieJar, @@ -202,7 +282,7 @@ pub(crate) async fn resolve_cart( // for the current viewer in one batch (the price depends on who's logged in). let user = guard::current_user(ctx, jar).await; let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new(); - for (id, qty) in parse_cart(jar) { + for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? { let Some((variant, product)) = published_variant(ctx, id).await? else { continue; }; @@ -238,6 +318,10 @@ pub(crate) async fn resolve_cart( })); } + if let Some(user) = user.as_ref() { + account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?; + } + Ok((lines, valid, total)) } @@ -250,8 +334,6 @@ async fn show( let cur = currency::resolve(&ctx, &jar).await; let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?; - // Drop any now-invalid lines from the cookie so the badge stays accurate. - let rebuilt = serialize_cart(&valid); let c = guard::chrome(&ctx, &jar).await; let response = format::view( &v, @@ -269,7 +351,9 @@ async fn show( }), )?; - Ok((jar.add(cart_cookie(rebuilt)), response).into_response()) + let user = guard::current_user(&ctx, &jar).await; + let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?; + Ok((jar, response).into_response()) } /// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from @@ -282,7 +366,6 @@ async fn preview( ) -> Result { let cur = currency::resolve(&ctx, &jar).await; let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?; - let rebuilt = serialize_cart(&valid); let response = format::view( &v, "shop/_cart_preview.html", @@ -293,7 +376,9 @@ async fn preview( "lang": current_lang(&jar), }), )?; - Ok((jar.add(cart_cookie(rebuilt)), response).into_response()) + let user = guard::current_user(&ctx, &jar).await; + let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?; + Ok((jar, response).into_response()) } pub fn routes() -> Routes { diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs index 828ffbf..b0d34b9 100644 --- a/src/controllers/checkout.rs +++ b/src/controllers/checkout.rs @@ -2,15 +2,13 @@ //! confirmation page. use axum::extract::Query; -use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use serde::Deserialize; use serde_json::json; -use time::Duration as TimeDuration; - use crate::{ - controllers::cart::{resolve_cart, CART_COOKIE}, + controllers::cart::{self, resolve_cart}, mailers::auth::AuthMailer, models::{ customer_profiles::{self, ProfileFields}, @@ -58,14 +56,6 @@ fn trimmed(value: &str) -> Option { (!value.is_empty()).then(|| value.to_string()) } -fn cleared_cart_cookie() -> Cookie<'static> { - Cookie::build((CART_COOKIE, "")) - .path("/") - .same_site(SameSite::Lax) - .max_age(TimeDuration::seconds(0)) - .build() -} - async fn enabled_shipping_methods(ctx: &AppContext) -> Result> { shipping_rules::disable_packeta_if_unconfigured(ctx).await?; let packeta_ready = shipping_rules::packeta_ready(ctx); @@ -388,8 +378,11 @@ async fn place_order( } else { format!("/orders/{}", order.order_number) }; + if let Some(user) = logged_in_customer { + cart::clear_account_cart(&ctx, user.id).await?; + } format::render() - .cookies(&[cleared_cart_cookie()])? + .cookies(&[cart::cleared_cart_cookie()])? .redirect(&target) } diff --git a/src/controllers/oauth2.rs b/src/controllers/oauth2.rs index 5ff5096..3e85d70 100644 --- a/src/controllers/oauth2.rs +++ b/src/controllers/oauth2.rs @@ -17,6 +17,7 @@ use loco_rs::prelude::*; use crate::{ controllers::auth as auth_controller, + controllers::cart, models::{o_auth2_sessions, users, users::OAuth2UserProfile}, shared::guard, }; @@ -36,8 +37,9 @@ async fn complete(State(ctx): State, user: GoogleCookieUser) -> Resu } else { "/" }; + let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?; format::render() - .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? + .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration), cart_cookie])? .redirect(dest) } diff --git a/src/models/_entities/account_cart_items.rs b/src/models/_entities/account_cart_items.rs new file mode 100644 index 0000000..6a5809f --- /dev/null +++ b/src/models/_entities/account_cart_items.rs @@ -0,0 +1,48 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "account_cart_items")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub variant_id: i32, + pub quantity: i32, + pub user_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::product_variants::Entity", + from = "Column::VariantId", + to = "super::product_variants::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + ProductVariants, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProductVariants.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index d0fd0fa..4fce4a9 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -2,6 +2,7 @@ pub mod prelude; +pub mod account_cart_items; pub mod account_discount_profiles; pub mod account_product_prices; pub mod account_product_resolutions; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 911c273..00d6947 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +pub use super::account_cart_items::Entity as AccountCartItems; pub use super::account_discount_profiles::Entity as AccountDiscountProfiles; pub use super::account_product_prices::Entity as AccountProductPrices; pub use super::account_product_resolutions::Entity as AccountProductResolutions; diff --git a/src/models/_entities/product_variants.rs b/src/models/_entities/product_variants.rs index 1f87e1d..8ea75bc 100644 --- a/src/models/_entities/product_variants.rs +++ b/src/models/_entities/product_variants.rs @@ -22,6 +22,8 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm(has_many = "super::account_cart_items::Entity")] + AccountCartItems, #[sea_orm(has_many = "super::account_product_prices::Entity")] AccountProductPrices, #[sea_orm(has_many = "super::account_product_resolutions::Entity")] @@ -38,6 +40,12 @@ pub enum Relation { Products, } +impl Related for Entity { + fn to() -> RelationDef { + Relation::AccountCartItems.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::AccountProductPrices.def() diff --git a/src/models/account_cart_items.rs b/src/models/account_cart_items.rs new file mode 100644 index 0000000..bab89d1 --- /dev/null +++ b/src/models/account_cart_items.rs @@ -0,0 +1,55 @@ +pub use crate::models::_entities::account_cart_items::{ActiveModel, Column, Entity, Model}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue, QueryFilter, QueryOrder}; + +pub type AccountCartItems = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} + +impl Model { + pub async fn find_for_user( + db: &DatabaseConnection, + user_id: i32, + ) -> Result, DbErr> { + Ok(Entity::find() + .filter(Column::UserId.eq(user_id)) + .order_by_asc(Column::Id) + .all(db) + .await? + .into_iter() + .filter_map(|item| (item.quantity > 0).then_some((item.variant_id, item.quantity))) + .collect()) + } + + pub async fn replace_for_user( + db: &DatabaseConnection, + user_id: i32, + items: &[(i32, i32)], + ) -> Result<(), DbErr> { + Entity::delete_many() + .filter(Column::UserId.eq(user_id)) + .exec(db) + .await?; + + for (variant_id, quantity) in items.iter().copied().filter(|(_, qty)| *qty > 0) { + ActiveModel { + user_id: ActiveValue::set(user_id), + variant_id: ActiveValue::set(variant_id), + quantity: ActiveValue::set(quantity), + ..Default::default() + } + .insert(db) + .await?; + } + + Ok(()) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 8bbc1aa..4af3350 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod _entities; +pub mod account_cart_items; pub mod account_discount_profiles; pub mod account_product_prices; pub mod account_product_resolutions;