//! Admin CRUD for reusable discount profiles (a named percentage over a product //! scope). Profiles are assigned to business accounts on the customer page; here //! the admin only defines them. use std::collections::HashSet; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; use serde_json::json; use crate::{ controllers::i18n::current_lang, models::{discount_profile_products, discount_profiles, products}, shared::{ guard, money::{format_bp, parse_percent, percent_to_bp}, }, }; /// Scalar + repeated fields parsed from the profile form. `product_ids` is a /// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect, /// so the body is parsed directly. struct ProfileInput { name: String, percent: String, scope_type: String, product_ids: Vec, } fn parse_profile_form(body: &str) -> ProfileInput { let mut name = String::new(); let mut percent = String::new(); let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string(); let mut product_ids = Vec::new(); for (key, value) in form_urlencoded::parse(body.as_bytes()) { match key.as_ref() { "name" => name = value.into_owned(), "percent" => percent = value.into_owned(), "scope_type" => scope_type = value.into_owned(), "product_ids" => { if let Ok(id) = value.parse::() { product_ids.push(id); } } _ => {} } } ProfileInput { name, percent, scope_type, product_ids, } } async fn profile_by_id(ctx: &AppContext, id: i32) -> Result { discount_profiles::Entity::find_by_id(id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound) } #[debug_handler] async fn index( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; let profiles = discount_profiles::Entity::find() .order_by_asc(discount_profiles::Column::Name) .all(&ctx.db) .await?; let mut rows = Vec::with_capacity(profiles.len()); for profile in &profiles { let count = discount_profile_products::Entity::find() .filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id)) .count(&ctx.db) .await?; rows.push(json!({ "id": profile.id, "name": profile.name, "percent": format_bp(profile.percent_bp), "scope_type": profile.scope_type, "product_count": count, })); } format::view( &v, "admin/catalog/discount_profiles.html", json!({ "profiles": rows, "lang": current_lang(&jar) }), ) } /// Render the create/edit form. `profile` is null on create. async fn render_form( ctx: &AppContext, v: &TeraView, jar: &CookieJar, profile: Option<&discount_profiles::Model>, selected: &HashSet, error: Option<&str>, ) -> Result { let all_products = products::Entity::find() .order_by_asc(products::Column::Name) .all(&ctx.db) .await?; let product_rows: Vec = all_products .iter() .map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) })) .collect(); let profile_json = match profile { Some(p) => json!({ "id": p.id, "name": p.name, "percent": format_bp(p.percent_bp), "scope_type": p.scope_type, }), None => serde_json::Value::Null, }; format::view( v, "admin/catalog/discount_profile_form.html", json!({ "profile": profile_json, "products": product_rows, "error": error, "lang": current_lang(jar), }), ) } #[debug_handler] async fn new( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await } #[debug_handler] async fn edit( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; let profile = profile_by_id(&ctx, id).await?; let selected = member_ids(&ctx, id).await?; render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await } async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result> { Ok(discount_profile_products::Entity::find() .filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id)) .all(&ctx.db) .await? .into_iter() .map(|r| r.product_id) .collect()) } /// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key. fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> { let name = input.name.trim().to_string(); if name.is_empty() { return Err("profile-name-required"); } let pct = parse_percent(&input.percent).ok_or("discount-invalid")?; if pct <= 0.0 || pct >= 100.0 { return Err("discount-percent-range"); } let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT { discount_profiles::SCOPE_ALL_EXCEPT } else { discount_profiles::SCOPE_INCLUDE }; Ok((name, percent_to_bp(pct), scope.to_string())) } /// Replace a profile's product membership with `product_ids`. async fn sync_membership( ctx: &AppContext, profile_id: i32, product_ids: &[i32], ) -> Result<()> { let txn = ctx.db.begin().await?; discount_profile_products::Entity::delete_many() .filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id)) .exec(&txn) .await?; for product_id in product_ids { discount_profile_products::ActiveModel { discount_profile_id: Set(profile_id), product_id: Set(*product_id), ..Default::default() } .insert(&txn) .await?; } txn.commit().await?; Ok(()) } #[debug_handler] async fn create( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, body: String, ) -> Result { guard::current_admin(auth, &ctx).await?; let input = parse_profile_form(&body); let (name, percent_bp, scope_type) = match validate(&input) { Ok(values) => values, Err(key) => { let selected: HashSet = input.product_ids.iter().copied().collect(); return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await; } }; let profile = discount_profiles::ActiveModel { name: Set(name), percent_bp: Set(percent_bp), scope_type: Set(scope_type), ..Default::default() } .insert(&ctx.db) .await?; sync_membership(&ctx, profile.id, &input.product_ids).await?; format::redirect("/admin/catalog/discount-profiles") } #[debug_handler] async fn update( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, State(ctx): State, body: String, ) -> Result { guard::current_admin(auth, &ctx).await?; let profile = profile_by_id(&ctx, id).await?; let input = parse_profile_form(&body); let (name, percent_bp, scope_type) = match validate(&input) { Ok(values) => values, Err(key) => { let selected: HashSet = input.product_ids.iter().copied().collect(); return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await; } }; let mut active = profile.into_active_model(); active.name = Set(name); active.percent_bp = Set(percent_bp); active.scope_type = Set(scope_type); active.update(&ctx.db).await?; sync_membership(&ctx, id, &input.product_ids).await?; format::redirect("/admin/catalog/discount-profiles") } #[debug_handler] async fn delete( auth: auth::JWT, Path(id): Path, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; // FK cascades remove membership, assignments and resolutions. profile_by_id(&ctx, id).await?.delete(&ctx.db).await?; format::redirect("/admin/catalog/discount-profiles") } pub fn routes() -> Routes { Routes::new() .add("/admin/catalog/discount-profiles", get(index)) .add("/admin/catalog/discount-profiles/new", get(new)) .add("/admin/catalog/discount-profiles", post(create)) .add("/admin/catalog/discount-profiles/{id}/edit", get(edit)) .add("/admin/catalog/discount-profiles/{id}", post(update)) .add("/admin/catalog/discount-profiles/{id}/delete", post(delete)) }