From 9ce07e8c23b08d76e6d568a82ef59f49708d25fd Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 16 Jun 2026 22:48:10 +0200 Subject: [PATCH] better project structure --- src/{controllers/auth.rs => account/mod.rs} | 23 +- src/account/models/mod.rs | 1 + src/{ => account}/models/users.rs | 4 +- src/{views/auth.rs => account/view.rs} | 0 src/admin/categories.rs | 269 +++++ src/admin/form.rs | 87 ++ src/admin/login.rs | 85 ++ src/admin/mod.rs | 63 ++ src/{ => admin}/models/audit_logs.rs | 2 +- src/admin/models/mod.rs | 1 + src/admin/orders.rs | 106 ++ src/admin/products.rs | 286 ++++++ src/admin/shipping.rs | 79 ++ src/app.rs | 33 +- src/{controllers/cart.rs => cart/mod.rs} | 5 +- src/checkout/mod.rs | 206 ++++ src/checkout/models/mod.rs | 3 + src/{ => checkout}/models/order_items.rs | 2 +- src/checkout/models/orders.rs | 127 +++ src/{ => checkout}/models/shipping_methods.rs | 2 +- src/checkout/view.rs | 64 ++ src/controllers/admin.rs | 57 - src/controllers/catalog.rs | 972 ------------------ src/controllers/frontend.rs | 138 --- src/controllers/mod.rs | 8 - src/controllers/orders.rs | 455 -------- src/home/mod.rs | 30 + src/{controllers/i18n.rs => i18n/mod.rs} | 0 src/initializers/admin_seeder.rs | 2 +- src/lib.rs | 17 +- src/mailers/auth.rs | 2 +- src/{controllers/media.rs => media/mod.rs} | 4 +- src/models/categories.rs | 28 - src/models/mod.rs | 16 +- src/models/orders.rs | 28 - src/models/product_images.rs | 28 - src/shared/guard.rs | 43 + src/shared/mod.rs | 6 + src/shared/money.rs | 36 + src/shared/settings.rs | 13 + src/shared/slug.rs | 38 + src/shop/mod.rs | 176 ++++ src/shop/models/categories.rs | 125 +++ src/shop/models/mod.rs | 5 + src/shop/models/product_images.rs | 47 + src/{ => shop}/models/product_product_tags.rs | 2 +- src/{ => shop}/models/product_tags.rs | 2 +- src/{ => shop}/models/products.rs | 2 +- src/shop/view.rs | 56 + src/views/mod.rs | 1 - tests/models/users.rs | 2 +- tests/requests/auth.rs | 2 +- tests/requests/prepare_data.rs | 2 +- 53 files changed, 2016 insertions(+), 1775 deletions(-) rename src/{controllers/auth.rs => account/mod.rs} (95%) create mode 100644 src/account/models/mod.rs rename src/{ => account}/models/users.rs (98%) rename src/{views/auth.rs => account/view.rs} (100%) create mode 100644 src/admin/categories.rs create mode 100644 src/admin/form.rs create mode 100644 src/admin/login.rs create mode 100644 src/admin/mod.rs rename src/{ => admin}/models/audit_logs.rs (86%) create mode 100644 src/admin/models/mod.rs create mode 100644 src/admin/orders.rs create mode 100644 src/admin/products.rs create mode 100644 src/admin/shipping.rs rename src/{controllers/cart.rs => cart/mod.rs} (98%) create mode 100644 src/checkout/mod.rs create mode 100644 src/checkout/models/mod.rs rename src/{ => checkout}/models/order_items.rs (89%) create mode 100644 src/checkout/models/orders.rs rename src/{ => checkout}/models/shipping_methods.rs (89%) create mode 100644 src/checkout/view.rs delete mode 100644 src/controllers/admin.rs delete mode 100644 src/controllers/catalog.rs delete mode 100644 src/controllers/frontend.rs delete mode 100644 src/controllers/mod.rs delete mode 100644 src/controllers/orders.rs create mode 100644 src/home/mod.rs rename src/{controllers/i18n.rs => i18n/mod.rs} (100%) rename src/{controllers/media.rs => media/mod.rs} (98%) delete mode 100644 src/models/categories.rs delete mode 100644 src/models/orders.rs delete mode 100644 src/models/product_images.rs create mode 100644 src/shared/guard.rs create mode 100644 src/shared/mod.rs create mode 100644 src/shared/money.rs create mode 100644 src/shared/settings.rs create mode 100644 src/shared/slug.rs create mode 100644 src/shop/mod.rs create mode 100644 src/shop/models/categories.rs create mode 100644 src/shop/models/mod.rs create mode 100644 src/shop/models/product_images.rs rename src/{ => shop}/models/product_product_tags.rs (85%) rename src/{ => shop}/models/product_tags.rs (90%) rename src/{ => shop}/models/products.rs (89%) create mode 100644 src/shop/view.rs delete mode 100644 src/views/mod.rs diff --git a/src/controllers/auth.rs b/src/account/mod.rs similarity index 95% rename from src/controllers/auth.rs rename to src/account/mod.rs index 57ff777..614983f 100644 --- a/src/controllers/auth.rs +++ b/src/account/mod.rs @@ -1,10 +1,11 @@ +pub mod models; +pub mod view; + use crate::{ + account::models::users::{self, LoginParams, RegisterParams}, + account::view::{CurrentResponse, LoginResponse}, mailers::auth::AuthMailer, - models::{ - _entities::users, - users::{LoginParams, RegisterParams}, - }, - views::auth::{CurrentResponse, LoginResponse}, + shared::guard::is_admin, }; use axum_extra::extract::cookie::{Cookie, SameSite}; use loco_rs::prelude::*; @@ -22,18 +23,6 @@ fn get_allow_email_domain_re() -> &'static Regex { }) } -fn admin_email(ctx: &AppContext) -> Option<&str> { - ctx.config - .settings - .as_ref() - .and_then(|settings| settings.get("admin_email")) - .and_then(|email| email.as_str()) -} - -fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { - admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) -} - pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { Cookie::build((AUTH_COOKIE, token.to_string())) .path("/") diff --git a/src/account/models/mod.rs b/src/account/models/mod.rs new file mode 100644 index 0000000..913bd46 --- /dev/null +++ b/src/account/models/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/src/models/users.rs b/src/account/models/users.rs similarity index 98% rename from src/models/users.rs rename to src/account/models/users.rs index 2292ded..431711a 100644 --- a/src/models/users.rs +++ b/src/account/models/users.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Map; use uuid::Uuid; -pub use super::_entities::users::{self, ActiveModel, Entity, Model}; +pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model}; pub const MAGIC_LINK_LENGTH: i8 = 32; pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; @@ -41,7 +41,7 @@ impl Validatable for ActiveModel { } #[async_trait::async_trait] -impl ActiveModelBehavior for super::_entities::users::ActiveModel { +impl ActiveModelBehavior for crate::models::_entities::users::ActiveModel { async fn before_save(self, _db: &C, insert: bool) -> Result where C: ConnectionTrait, diff --git a/src/views/auth.rs b/src/account/view.rs similarity index 100% rename from src/views/auth.rs rename to src/account/view.rs diff --git a/src/admin/categories.rs b/src/admin/categories.rs new file mode 100644 index 0000000..4186ee1 --- /dev/null +++ b/src/admin/categories.rs @@ -0,0 +1,269 @@ +//! Admin category CRUD, including parent/child hierarchy management. + +use std::collections::HashSet; + +use axum::extract::{DefaultBodyLimit, Multipart}; +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, Set, +}; +use serde_json::json; + +use crate::{ + admin::form::{read_multipart_form, store_image, MultipartForm}, + i18n::current_lang, + media::IMAGE_MAX_BYTES, + shared::{ + guard, + slug::{slugify, unique_slug}, + }, + shop::models::{categories, products}, +}; + +async fn category_by_id(ctx: &AppContext, id: i32) -> Result { + categories::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +/// Fields parsed from a category form. +struct CategoryFields { + name: String, + slug: String, + description: Option, + position: i32, + published: bool, + parent_id: Option, +} + +async fn parse_category_fields( + ctx: &AppContext, + form: &MultipartForm, + current_id: Option, +) -> Result { + let name = form + .text("name") + .ok_or_else(|| Error::BadRequest("category name is required".to_string()))?; + let description = form.text("description"); + let position = form + .text("position") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let published = form.checked("published"); + + // Resolve the chosen parent, rejecting cycles: a category may not be its + // own parent nor be re-parented under one of its descendants. + let parent_id = match form.text("parent_id").and_then(|s| s.parse::().ok()) { + Some(parent_id) => { + categories::Entity::find_by_id(parent_id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?; + if let Some(id) = current_id { + if parent_id == id { + return Err(Error::BadRequest( + "a category cannot be its own parent".to_string(), + )); + } + if categories::descendant_ids(&categories::all(ctx).await?, id).contains(&parent_id) + { + return Err(Error::BadRequest( + "a category cannot be moved under its own descendant".to_string(), + )); + } + } + Some(parent_id) + } + None => None, + }; + + let desired = form + .text("slug") + .map(|s| slugify(&s)) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| slugify(&name)); + let slug = unique_slug(&desired, |candidate| { + let ctx = ctx.clone(); + async move { + let mut query = + categories::Entity::find().filter(categories::Column::Slug.eq(candidate)); + if let Some(id) = current_id { + query = query.filter(categories::Column::Id.ne(id)); + } + Ok(query.count(&ctx.db).await? > 0) + } + }) + .await?; + + Ok(CategoryFields { + name, + slug, + description, + position, + published, + parent_id, + }) +} + +/// Build the parent-category dropdown options for the category form, as a +/// depth-ordered list of `{ id, name, depth }`. When editing, the category +/// itself and all of its descendants are excluded to keep the tree acyclic. +async fn form_context( + ctx: &AppContext, + jar: &CookieJar, + editing: Option, +) -> Result { + let all = categories::all(ctx).await?; + let blocked: HashSet = match editing { + Some(id) => { + let mut set = categories::descendant_ids(&all, id); + set.insert(id); + set + } + None => HashSet::new(), + }; + let parents: Vec = categories::tree(&all) + .into_iter() + .filter(|(category, _)| !blocked.contains(&category.id)) + .map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth })) + .collect(); + Ok(json!({ "parents": parents, "lang": current_lang(jar) })) +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let list = categories::all(&ctx).await?; + let mut rows = Vec::new(); + for (category, depth) in categories::tree(&list) { + let product_count = products::Entity::find() + .filter(products::Column::CategoryId.eq(category.id)) + .count(&ctx.db) + .await?; + rows.push(json!({ "category": category, "depth": depth, "product_count": product_count })); + } + format::view( + &v, + "admin/catalog/categories.html", + json!({ "categories": rows, "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?; + let mut context = form_context(&ctx, &jar, None).await?; + context["category"] = serde_json::Value::Null; + format::view(&v, "admin/catalog/category_form.html", context) +} + +#[debug_handler] +async fn create( + auth: auth::JWT, + State(ctx): State, + multipart: Multipart, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let form = read_multipart_form(multipart).await?; + let fields = parse_category_fields(&ctx, &form, None).await?; + let image_id = match form.image { + Some(data) => Some(store_image(&ctx, data).await?), + None => None, + }; + + categories::ActiveModel { + name: Set(fields.name), + slug: Set(fields.slug), + description: Set(fields.description), + image_id: Set(image_id), + position: Set(fields.position), + published: Set(fields.published), + parent_id: Set(fields.parent_id), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + format::redirect("/admin/catalog/categories") +} + +#[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 mut context = form_context(&ctx, &jar, Some(id)).await?; + context["category"] = json!(category_by_id(&ctx, id).await?); + format::view(&v, "admin/catalog/category_form.html", context) +} + +#[debug_handler] +async fn update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + multipart: Multipart, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let existing = category_by_id(&ctx, id).await?; + let form = read_multipart_form(multipart).await?; + let fields = parse_category_fields(&ctx, &form, Some(id)).await?; + + let mut category = existing.into_active_model(); + category.name = Set(fields.name); + category.slug = Set(fields.slug); + category.description = Set(fields.description); + category.position = Set(fields.position); + category.published = Set(fields.published); + category.parent_id = Set(fields.parent_id); + if let Some(data) = form.image { + category.image_id = Set(Some(store_image(&ctx, data).await?)); + } + category.update(&ctx.db).await?; + + format::redirect("/admin/catalog/categories") +} + +#[debug_handler] +async fn delete( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + category_by_id(&ctx, id).await?.delete(&ctx.db).await?; + format::redirect("/admin/catalog/categories") +} + +pub fn routes() -> Routes { + let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024); + Routes::new() + .add("/admin/catalog/categories", get(index)) + .add("/admin/catalog/categories/new", get(new)) + .add( + "/admin/catalog/categories", + post(create).layer(image_limit.clone()), + ) + .add("/admin/catalog/categories/{id}/edit", get(edit)) + .add( + "/admin/catalog/categories/{id}", + post(update).layer(image_limit), + ) + .add("/admin/catalog/categories/{id}/delete", post(delete)) +} diff --git a/src/admin/form.rs b/src/admin/form.rs new file mode 100644 index 0000000..2b8c3e1 --- /dev/null +++ b/src/admin/form.rs @@ -0,0 +1,87 @@ +//! Multipart form handling shared by the product and category admin forms. +//! +//! Both forms submit a mix of text fields and an optional `image` file part; +//! this collects them into an easy-to-query [`MultipartForm`] and stores any +//! uploaded image through the configured storage driver. + +use std::collections::HashMap; + +use axum::extract::Multipart; +use loco_rs::prelude::*; + +use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR}; + +fn normalize_empty(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) + }) +} + +/// Collected multipart form: text fields keyed by name, plus the raw bytes of +/// an `image` file part if one was uploaded (an empty file input is ignored). +pub(crate) struct MultipartForm { + fields: HashMap, + pub(crate) image: Option>, +} + +impl MultipartForm { + /// Trimmed value of a text field, `None` when missing or blank. + pub(crate) fn text(&self, key: &str) -> Option { + normalize_empty(self.fields.get(key).cloned()) + } + + /// Whether a checkbox-style field is checked. + pub(crate) fn checked(&self, key: &str) -> bool { + matches!( + self.fields.get(key).map(String::as_str), + Some("on" | "true" | "1") + ) + } +} + +pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result { + let mut fields = HashMap::new(); + let mut image = None; + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? + { + let name = field.name().unwrap_or("").to_string(); + if name == "image" { + let mut data = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? + { + data.extend_from_slice(&chunk); + if data.len() > IMAGE_MAX_BYTES { + return Err(Error::BadRequest(format!( + "image is larger than {} MB", + IMAGE_MAX_BYTES / 1024 / 1024 + ))); + } + } + if !data.is_empty() { + image = Some(data); + } + } else { + let value = field + .text() + .await + .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; + fields.insert(name, value); + } + } + + Ok(MultipartForm { fields, image }) +} + +/// Store an uploaded image's bytes and return its generated filename. +pub(crate) async fn store_image(ctx: &AppContext, data: Vec) -> Result { + let extension = detect_image_extension(&data)?; + store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await +} diff --git a/src/admin/login.rs b/src/admin/login.rs new file mode 100644 index 0000000..c9438a7 --- /dev/null +++ b/src/admin/login.rs @@ -0,0 +1,85 @@ +//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth` +//! flow used by the SPA/API). + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use serde_json::json; + +use crate::{ + account::{self as auth_controller, models::users::{self, LoginParams}}, + i18n::current_lang, + shared::guard, +}; + +fn login_error(v: &TeraView, jar: &CookieJar) -> Result { + format::view( + v, + "admin/login.html", + json!({ + "error": "Invalid credentials", + "logged_in_admin": false, + "lang": current_lang(jar), + }), + ) +} + +#[debug_handler] +async fn login_page( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + if guard::logged_in(&ctx, &jar).await { + return format::redirect("/admin/dashboard"); + } + + format::view( + &v, + "admin/login.html", + json!({ + "error": null, + "logged_in_admin": false, + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn login( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, + Form(params): Form, +) -> Result { + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + return login_error(&v, &jar); + }; + + if !user.verify_password(¶ms.password) || !guard::is_admin(&ctx, &user) { + return login_error(&v, &jar); + } + + let jwt_secret = ctx.config.get_jwt_config()?; + let token = user + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::render() + .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? + .redirect("/admin/dashboard") +} + +#[debug_handler] +async fn logout() -> Result { + format::render() + .cookies(&[auth_controller::clear_auth_cookie()])? + .redirect("/admin/login") +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin", get(login_page)) + .add("/admin/login", get(login_page)) + .add("/admin/login", post(login)) + .add("/admin/logout", post(logout)) +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..27c2cbc --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,63 @@ +//! Admin area. Each surface lives in its own submodule; this module holds the +//! dashboard (HTML home + JSON stats) and is the entry point for admin routes. + +pub mod categories; +pub mod form; +pub mod login; +pub mod models; +pub mod orders; +pub mod products; +pub mod shipping; + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{EntityTrait, PaginatorTrait}; +use serde::Serialize; +use serde_json::json; + +use crate::{i18n::current_lang, models::_entities, shared::guard}; + +#[derive(Debug, Serialize)] +struct DashboardResponse { + users: u64, + products: u64, + categories: u64, + orders: u64, + audit_logs: u64, +} + +/// JSON dashboard stats, served under `/api/admin`. +#[debug_handler] +async fn dashboard_json(auth: auth::JWT, State(ctx): State) -> Result { + guard::current_admin(auth, &ctx).await?; + + format::json(DashboardResponse { + users: _entities::users::Entity::find().count(&ctx.db).await?, + products: _entities::products::Entity::find().count(&ctx.db).await?, + categories: _entities::categories::Entity::find().count(&ctx.db).await?, + orders: _entities::orders::Entity::find().count(&ctx.db).await?, + audit_logs: _entities::audit_logs::Entity::find().count(&ctx.db).await?, + }) +} + +/// HTML admin home page. +#[debug_handler] +async fn dashboard_page( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let admin_user = guard::current_admin(auth, &ctx).await?; + format::view( + &v, + "admin/index.html", + json!({ "admin": admin_user, "lang": current_lang(&jar) }), + ) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin/dashboard", get(dashboard_page)) + .add("/api/admin/dashboard", get(dashboard_json)) +} diff --git a/src/models/audit_logs.rs b/src/admin/models/audit_logs.rs similarity index 86% rename from src/models/audit_logs.rs rename to src/admin/models/audit_logs.rs index 787b25d..5013399 100644 --- a/src/models/audit_logs.rs +++ b/src/admin/models/audit_logs.rs @@ -1,4 +1,4 @@ -pub use super::_entities::audit_logs::{ActiveModel, Entity, Model}; +pub use crate::models::_entities::audit_logs::{ActiveModel, Entity, Model}; use sea_orm::entity::prelude::*; pub type AuditLogs = Entity; diff --git a/src/admin/models/mod.rs b/src/admin/models/mod.rs new file mode 100644 index 0000000..45b0b44 --- /dev/null +++ b/src/admin/models/mod.rs @@ -0,0 +1 @@ +pub mod audit_logs; diff --git a/src/admin/orders.rs b/src/admin/orders.rs new file mode 100644 index 0000000..acbd91e --- /dev/null +++ b/src/admin/orders.rs @@ -0,0 +1,106 @@ +//! Admin order list, detail, and status updates. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + checkout::{ + models::{order_items, orders}, + view, + }, + i18n::current_lang, + shared::{guard, settings}, +}; + +pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"]; + +#[derive(Debug, Deserialize)] +struct StatusForm { + status: String, +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let list = orders::Entity::find() + .order_by_desc(orders::Column::CreatedAt) + .all(&ctx.db) + .await?; + let rows: Vec = list.iter().map(view::summary).collect(); + format::view( + &v, + "admin/orders/index.html", + json!({ "orders": rows, "lang": current_lang(&jar) }), + ) +} + +#[debug_handler] +async fn show( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(id): Path, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let order = orders::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let items = order_items::Entity::find() + .filter(order_items::Column::OrderId.eq(order.id)) + .all(&ctx.db) + .await?; + + format::view( + &v, + "admin/orders/show.html", + json!({ + "order": view::detail( + &order, + settings::get(&ctx, "bank_iban").unwrap_or(""), + settings::get(&ctx, "bank_account_name").unwrap_or(""), + ), + "items": view::items(&items), + "statuses": ORDER_STATUSES, + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn update_status( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + guard::current_admin(auth, &ctx).await?; + if !ORDER_STATUSES.contains(&form.status.as_str()) { + return Err(Error::BadRequest("invalid status".to_string())); + } + let order = orders::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let mut active = order.into_active_model(); + active.status = Set(form.status); + active.update(&ctx.db).await?; + + format::redirect(&format!("/admin/orders/{id}")) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin/orders", get(index)) + .add("/admin/orders/{id}", get(show)) + .add("/admin/orders/{id}/status", post(update_status)) +} diff --git a/src/admin/products.rs b/src/admin/products.rs new file mode 100644 index 0000000..a7089ec --- /dev/null +++ b/src/admin/products.rs @@ -0,0 +1,286 @@ +//! Admin product CRUD. + +use axum::extract::{DefaultBodyLimit, Multipart}; +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; +use serde_json::json; + +use crate::{ + admin::form::{read_multipart_form, store_image, MultipartForm}, + i18n::current_lang, + media::IMAGE_MAX_BYTES, + shared::{ + guard, + money::parse_price_to_cents, + slug::{slugify, unique_slug}, + }, + shop::{ + models::{categories, product_images, products}, + view, + }, +}; + +async fn product_by_id(ctx: &AppContext, id: i32) -> Result { + products::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound) +} + +/// Fields parsed from a product form, ready to apply to an active model. +struct ProductFields { + name: String, + slug: String, + description: Option, + price_cents: i64, + currency: String, + sku: Option, + stock: i32, + category_id: Option, + published: bool, +} + +async fn parse_product_fields( + ctx: &AppContext, + form: &MultipartForm, + current_id: Option, +) -> Result { + let name = form + .text("name") + .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?; + let price_cents = parse_price_to_cents( + form.text("price") + .ok_or_else(|| Error::BadRequest("price is required".to_string()))? + .as_str(), + )?; + let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string()); + let description = form.text("description"); + let sku = form.text("sku"); + let stock = form + .text("stock") + .and_then(|s| s.parse::().ok()) + .filter(|n| *n >= 0) + .unwrap_or(0); + let category_id = form.text("category_id").and_then(|s| s.parse::().ok()); + let published = form.checked("published"); + + let desired = form + .text("slug") + .map(|s| slugify(&s)) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| slugify(&name)); + let slug = unique_slug(&desired, |candidate| { + let ctx = ctx.clone(); + async move { + let mut query = products::Entity::find().filter(products::Column::Slug.eq(candidate)); + if let Some(id) = current_id { + query = query.filter(products::Column::Id.ne(id)); + } + Ok(query.count(&ctx.db).await? > 0) + } + }) + .await?; + + Ok(ProductFields { + name, + slug, + description, + price_cents, + currency, + sku, + stock, + category_id, + published, + }) +} + +async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result { + let categories = categories::Entity::find() + .order_by_asc(categories::Column::Position) + .order_by_asc(categories::Column::Name) + .all(&ctx.db) + .await?; + Ok(json!({ "categories": categories, "lang": current_lang(jar) })) +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let list = products::Entity::find() + .order_by_desc(products::Column::CreatedAt) + .all(&ctx.db) + .await?; + let mut rows = Vec::new(); + for product in list { + let image = product_images::first_for(&ctx, product.id).await?; + let category_name = match product.category_id { + Some(id) => categories::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .map(|c| c.name), + None => None, + }; + rows.push(view::product_card(&product, image, category_name)); + } + format::view( + &v, + "admin/catalog/products.html", + json!({ "products": rows, "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?; + let mut context = form_context(&ctx, &jar).await?; + context["product"] = serde_json::Value::Null; + format::view(&v, "admin/catalog/product_form.html", context) +} + +#[debug_handler] +async fn create( + auth: auth::JWT, + State(ctx): State, + multipart: Multipart, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let form = read_multipart_form(multipart).await?; + let fields = parse_product_fields(&ctx, &form, None).await?; + + let product = products::ActiveModel { + name: Set(fields.name), + slug: Set(fields.slug), + description: Set(fields.description), + price_cents: Set(fields.price_cents), + currency: Set(fields.currency), + sku: Set(fields.sku), + stock: Set(fields.stock), + view_count: Set(0), + published: Set(fields.published), + published_at: Set(fields.published.then(|| chrono::Utc::now().into())), + category_id: Set(fields.category_id), + ..Default::default() + } + .insert(&ctx.db) + .await?; + + if let Some(data) = form.image { + let filename = store_image(&ctx, data).await?; + product_images::ActiveModel { + product_id: Set(product.id), + image_id: Set(filename), + position: Set(0), + alt: Set(None), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } + + format::redirect("/admin/catalog/products") +} + +#[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 product = product_by_id(&ctx, id).await?; + let image = product_images::first_for(&ctx, id).await?; + let mut context = form_context(&ctx, &jar).await?; + context["product"] = view::product_form(&product, image); + format::view(&v, "admin/catalog/product_form.html", context) +} + +#[debug_handler] +async fn update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + multipart: Multipart, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let existing = product_by_id(&ctx, id).await?; + let was_published = existing.published; + let form = read_multipart_form(multipart).await?; + let fields = parse_product_fields(&ctx, &form, Some(id)).await?; + + let mut product = existing.into_active_model(); + product.name = Set(fields.name); + product.slug = Set(fields.slug); + product.description = Set(fields.description); + product.price_cents = Set(fields.price_cents); + product.currency = Set(fields.currency); + product.sku = Set(fields.sku); + product.stock = Set(fields.stock); + product.category_id = Set(fields.category_id); + product.published = Set(fields.published); + if fields.published && !was_published { + product.published_at = Set(Some(chrono::Utc::now().into())); + } else if !fields.published { + product.published_at = Set(None); + } + product.update(&ctx.db).await?; + + if let Some(data) = form.image { + let filename = store_image(&ctx, data).await?; + let next_position = product_images::count_for(&ctx, id).await?; + product_images::ActiveModel { + product_id: Set(id), + image_id: Set(filename), + position: Set(next_position), + alt: Set(None), + ..Default::default() + } + .insert(&ctx.db) + .await?; + } + + format::redirect("/admin/catalog/products") +} + +#[debug_handler] +async fn delete( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + product_by_id(&ctx, id).await?.delete(&ctx.db).await?; + format::redirect("/admin/catalog/products") +} + +pub fn routes() -> Routes { + let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024); + Routes::new() + .add("/admin/catalog/products", get(index)) + .add("/admin/catalog/products/new", get(new)) + .add( + "/admin/catalog/products", + post(create).layer(image_limit.clone()), + ) + .add("/admin/catalog/products/{id}/edit", get(edit)) + .add( + "/admin/catalog/products/{id}", + post(update).layer(image_limit), + ) + .add("/admin/catalog/products/{id}/delete", post(delete)) +} diff --git a/src/admin/shipping.rs b/src/admin/shipping.rs new file mode 100644 index 0000000..717ff8e --- /dev/null +++ b/src/admin/shipping.rs @@ -0,0 +1,79 @@ +//! Admin management of shipping methods (price + enabled toggle). + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + checkout::models::shipping_methods, + i18n::current_lang, + shared::{ + guard, + money::{format_price, parse_price_to_cents}, + }, +}; + +#[derive(Debug, Deserialize)] +struct ShippingForm { + price: String, + enabled: Option, +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let methods = shipping_methods::Entity::find() + .order_by_asc(shipping_methods::Column::Position) + .all(&ctx.db) + .await?; + let rows: Vec = methods + .iter() + .map(|m| { + json!({ + "id": m.id, + "code": m.code, + "name": m.name, + "price": format_price(m.price_cents), + "requires_pickup_point": m.requires_pickup_point, + "enabled": m.enabled, + }) + }) + .collect(); + format::view( + &v, + "admin/shipping/index.html", + json!({ "methods": rows, "lang": current_lang(&jar) }), + ) +} + +#[debug_handler] +async fn update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let method = shipping_methods::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let mut active = method.into_active_model(); + active.price_cents = Set(parse_price_to_cents(&form.price)?); + active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1"))); + active.update(&ctx.db).await?; + format::redirect("/admin/shipping") +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin/shipping", get(index)) + .add("/admin/shipping/{id}", post(update)) +} diff --git a/src/app.rs b/src/app.rs index 8084335..df645df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,8 @@ use std::{path::Path, sync::Arc}; #[allow(unused_imports)] use crate::{ - controllers, initializers, models::_entities::users, tasks, workers::downloader::DownloadWorker, + account, admin, cart, checkout, home, i18n, initializers, media, + models::_entities::users, shop, tasks, workers::downloader::DownloadWorker, }; pub struct App; @@ -58,20 +59,28 @@ impl Hooks for App { } fn routes(_ctx: &AppContext) -> AppRoutes { - AppRoutes::with_default_routes() // controller routes below - .add_route(controllers::auth::routes()) - .add_route(controllers::admin::routes()) - .add_route(controllers::catalog::routes()) - .add_route(controllers::cart::routes()) - .add_route(controllers::orders::routes()) - .add_route(controllers::i18n::routes()) - .add_route(controllers::media::routes()) - .add_route(controllers::frontend::routes()) + AppRoutes::with_default_routes() // feature routes below + // public + .add_route(home::routes()) + .add_route(shop::routes()) + .add_route(cart::routes()) + .add_route(checkout::routes()) + // cross-cutting + .add_route(account::routes()) + .add_route(i18n::routes()) + .add_route(media::routes()) + // admin + .add_route(admin::routes()) + .add_route(admin::login::routes()) + .add_route(admin::products::routes()) + .add_route(admin::categories::routes()) + .add_route(admin::orders::routes()) + .add_route(admin::shipping::routes()) } async fn after_context(ctx: AppContext) -> Result { - let upload_root = crate::controllers::media::uploads_root(&ctx.config)?; - tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?; + let upload_root = media::uploads_root(&ctx.config)?; + tokio::fs::create_dir_all(upload_root.join(media::IMAGE_STORAGE_DIR)).await?; let driver = storage::drivers::local::new_with_prefix(&upload_root)?; Ok(AppContext { diff --git a/src/controllers/cart.rs b/src/cart/mod.rs similarity index 98% rename from src/controllers/cart.rs rename to src/cart/mod.rs index 783d68f..b726751 100644 --- a/src/controllers/cart.rs +++ b/src/cart/mod.rs @@ -1,7 +1,4 @@ -use crate::{ - controllers::{catalog::format_price, i18n::current_lang}, - models::_entities::products, -}; +use crate::{i18n::current_lang, shared::money::format_price, shop::models::products}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; diff --git a/src/checkout/mod.rs b/src/checkout/mod.rs new file mode 100644 index 0000000..340a8b7 --- /dev/null +++ b/src/checkout/mod.rs @@ -0,0 +1,206 @@ +//! Public checkout flow: the checkout form, placing an order, and the order +//! confirmation page. + +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use serde::Deserialize; +use serde_json::json; +use time::Duration as TimeDuration; + +pub mod models; +pub mod view; + +use crate::{ + cart::{resolve_cart, CART_COOKIE}, + checkout::models::{ + order_items, + orders::{self, Checkout}, + shipping_methods, + }, + i18n::current_lang, + shared::{money::format_price, settings}, +}; + +const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"]; + +#[derive(Debug, Deserialize)] +struct CheckoutForm { + email: String, + customer_name: String, + address: String, + city: String, + zip: String, + country: String, + note: Option, + payment_method: String, + carrier_code: String, + pickup_point_id: Option, + pickup_point_name: Option, +} + +fn trimmed(value: &str) -> Option { + let value = value.trim(); + (!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> { + Ok(shipping_methods::Entity::find() + .filter(shipping_methods::Column::Enabled.eq(true)) + .order_by_asc(shipping_methods::Column::Position) + .all(&ctx.db) + .await?) +} + +#[debug_handler] +async fn checkout_page( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; + if lines.is_empty() { + return format::redirect("/cart"); + } + let currency = lines + .first() + .and_then(|line| line["currency"].as_str()) + .unwrap_or("EUR") + .to_string(); + + let methods: Vec = enabled_shipping_methods(&ctx) + .await? + .iter() + .map(|m| { + json!({ + "code": m.code, + "name": m.name, + "price_cents": m.price_cents, + "price": format_price(m.price_cents), + "requires_pickup_point": m.requires_pickup_point, + }) + }) + .collect(); + + format::view( + &v, + "shop/checkout.html", + json!({ + "items": lines, + "subtotal": format_price(subtotal), + "subtotal_cents": subtotal, + "currency": currency, + "shipping_methods": methods, + "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn place_order( + jar: CookieJar, + State(ctx): State, + Form(form): Form, +) -> Result { + let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?; + if valid.is_empty() { + return format::redirect("/cart"); + } + let email = + trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?; + + if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) { + return Err(Error::BadRequest("invalid payment method".to_string())); + } + + // Resolve the chosen carrier from the enabled methods (price is taken from + // the DB, never the form, so the customer can't pick their own fee). + let method = shipping_methods::Entity::find() + .filter(shipping_methods::Column::Code.eq(&form.carrier_code)) + .filter(shipping_methods::Column::Enabled.eq(true)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?; + + let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point { + let id = form + .pickup_point_id + .as_deref() + .and_then(trimmed) + .ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?; + (Some(id), form.pickup_point_name.as_deref().and_then(trimmed)) + } else { + (None, None) + }; + + let order = orders::place( + &ctx, + &valid, + Checkout { + email, + customer_name: trimmed(&form.customer_name), + address: trimmed(&form.address), + city: trimmed(&form.city), + zip: trimmed(&form.zip), + country: trimmed(&form.country), + note: form.note.as_deref().and_then(trimmed), + payment_method: form.payment_method, + method, + pickup_point_id, + pickup_point_name, + }, + ) + .await?; + + format::render() + .cookies(&[cleared_cart_cookie()])? + .redirect(&format!("/orders/{}", order.order_number)) +} + +#[debug_handler] +async fn order_confirmation( + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(order_number): Path, + State(ctx): State, +) -> Result { + let order = orders::Entity::find() + .filter(orders::Column::OrderNumber.eq(order_number)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let items = order_items::Entity::find() + .filter(order_items::Column::OrderId.eq(order.id)) + .all(&ctx.db) + .await?; + + format::view( + &v, + "shop/order_confirmed.html", + json!({ + "order": view::detail( + &order, + settings::get(&ctx, "bank_iban").unwrap_or(""), + settings::get(&ctx, "bank_account_name").unwrap_or(""), + ), + "items": view::items(&items), + "lang": current_lang(&jar), + }), + ) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/checkout", get(checkout_page)) + .add("/checkout", post(place_order)) + .add("/orders/{order_number}", get(order_confirmation)) +} diff --git a/src/checkout/models/mod.rs b/src/checkout/models/mod.rs new file mode 100644 index 0000000..6048c01 --- /dev/null +++ b/src/checkout/models/mod.rs @@ -0,0 +1,3 @@ +pub mod order_items; +pub mod orders; +pub mod shipping_methods; diff --git a/src/models/order_items.rs b/src/checkout/models/order_items.rs similarity index 89% rename from src/models/order_items.rs rename to src/checkout/models/order_items.rs index 5597344..a0dbe9d 100644 --- a/src/models/order_items.rs +++ b/src/checkout/models/order_items.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -pub use super::_entities::order_items::{ActiveModel, Model, Entity}; +pub use crate::models::_entities::order_items::{ActiveModel, Column, Entity, Model}; pub type OrderItems = Entity; #[async_trait::async_trait] diff --git a/src/checkout/models/orders.rs b/src/checkout/models/orders.rs new file mode 100644 index 0000000..fbfcf77 --- /dev/null +++ b/src/checkout/models/orders.rs @@ -0,0 +1,127 @@ +use loco_rs::prelude::*; +use sea_orm::entity::prelude::*; +use sea_orm::{Set, TransactionTrait}; +use uuid::Uuid; + +use crate::models::_entities::{order_items, products, shipping_methods}; +pub use crate::models::_entities::orders::{ActiveModel, Column, Entity, Model}; +pub type Orders = Entity; + +/// The customer-supplied and carrier details needed to place an order. Prices +/// and product names are never taken from here — they are snapshotted from the +/// database inside [`place`] so the customer cannot influence what they pay. +pub struct Checkout { + pub email: String, + pub customer_name: Option, + pub address: Option, + pub city: Option, + pub zip: Option, + pub country: Option, + pub note: Option, + pub payment_method: String, + pub method: shipping_methods::Model, + pub pickup_point_id: Option, + pub pickup_point_name: Option, +} + +fn generate_order_number() -> String { + let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase(); + format!("ORD-{suffix}") +} + +/// Atomically place an order for the given `(product_id, quantity)` lines: +/// snapshot each product's price/name, decrement stock (re-checking inside the +/// transaction so an item can't oversell between cart and pay), then write the +/// order and its line items. Returns the persisted order. +pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> Result { + let txn = ctx.db.begin().await?; + + let mut subtotal: i64 = 0; + let mut currency = "EUR".to_string(); + let mut snapshots = Vec::new(); + for (product_id, qty) in items { + let product = products::Entity::find_by_id(*product_id) + .filter(products::Column::Published.eq(true)) + .one(&txn) + .await? + .ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?; + if product.stock < *qty { + return Err(Error::BadRequest(format!( + "not enough stock for {}", + product.name + ))); + } + currency = product.currency.clone(); + subtotal += product.price_cents * i64::from(*qty); + + let mut active = product.clone().into_active_model(); + active.stock = Set(product.stock - *qty); + active.update(&txn).await?; + + snapshots.push((product.id, product.name, product.price_cents, *qty)); + } + + let order = ActiveModel { + order_number: Set(generate_order_number()), + email: Set(details.email), + customer_name: Set(details.customer_name), + status: Set("pending".to_string()), + total_cents: Set(subtotal + details.method.price_cents), + currency: Set(currency), + address: Set(details.address), + city: Set(details.city), + zip: Set(details.zip), + country: Set(details.country), + note: Set(details.note), + payment_method: Set(Some(details.payment_method)), + carrier_code: Set(Some(details.method.code)), + carrier_name: Set(Some(details.method.name)), + shipping_cents: Set(details.method.price_cents), + pickup_point_id: Set(details.pickup_point_id), + pickup_point_name: Set(details.pickup_point_name), + ..Default::default() + } + .insert(&txn) + .await?; + + for (product_id, name, unit_price_cents, qty) in snapshots { + order_items::ActiveModel { + order_id: Set(order.id), + product_id: Set(Some(product_id)), + product_name: Set(name), + unit_price_cents: Set(unit_price_cents), + quantity: Set(qty), + ..Default::default() + } + .insert(&txn) + .await?; + } + + txn.commit().await?; + Ok(order) +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/src/models/shipping_methods.rs b/src/checkout/models/shipping_methods.rs similarity index 89% rename from src/models/shipping_methods.rs rename to src/checkout/models/shipping_methods.rs index 3b95d29..4a4b9b6 100644 --- a/src/models/shipping_methods.rs +++ b/src/checkout/models/shipping_methods.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -pub use super::_entities::shipping_methods::{ActiveModel, Model, Entity}; +pub use crate::models::_entities::shipping_methods::{ActiveModel, Column, Entity, Model}; pub type ShippingMethods = Entity; #[async_trait::async_trait] diff --git a/src/checkout/view.rs b/src/checkout/view.rs new file mode 100644 index 0000000..b8ae5f5 --- /dev/null +++ b/src/checkout/view.rs @@ -0,0 +1,64 @@ +//! JSON shaping for order confirmation and admin order templates. + +use serde_json::{json, Value}; + +use crate::models::_entities::{order_items, orders}; +use crate::shared::money::format_price; + +/// Line items of an order, shaped for templates. +pub fn items(items: &[order_items::Model]) -> Vec { + items + .iter() + .map(|item| { + json!({ + "product_name": item.product_name, + "quantity": item.quantity, + "unit_price": format_price(item.unit_price_cents), + "line_total": format_price(item.unit_price_cents * i64::from(item.quantity)), + }) + }) + .collect() +} + +/// Full order detail for the confirmation and admin show pages. `bank_iban` and +/// `bank_account_name` come from settings and are embedded for bank-transfer +/// payment instructions. +pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -> Value { + json!({ + "id": order.id, + "order_number": order.order_number, + "email": order.email, + "customer_name": order.customer_name, + "status": order.status, + "subtotal": format_price(order.total_cents - order.shipping_cents), + "shipping": format_price(order.shipping_cents), + "total": format_price(order.total_cents), + "currency": order.currency, + "address": order.address, + "city": order.city, + "zip": order.zip, + "country": order.country, + "note": order.note, + "payment_method": order.payment_method, + "carrier_name": order.carrier_name, + "pickup_point_name": order.pickup_point_name, + // Numeric, sequential order id doubles as the bank variable symbol. + "variable_symbol": order.id, + "bank_iban": bank_iban, + "bank_account_name": bank_account_name, + "created_at": order.created_at.to_rfc3339(), + }) +} + +/// Compact row for the admin orders list. +pub fn summary(order: &orders::Model) -> Value { + json!({ + "id": order.id, + "order_number": order.order_number, + "email": order.email, + "status": order.status, + "total": format_price(order.total_cents), + "currency": order.currency, + "created_at": order.created_at.to_rfc3339(), + }) +} diff --git a/src/controllers/admin.rs b/src/controllers/admin.rs deleted file mode 100644 index eb21ad4..0000000 --- a/src/controllers/admin.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::models::{ - _entities::{audit_logs, categories, orders, products, users}, - users as users_model, -}; -use loco_rs::prelude::*; -use sea_orm::{EntityTrait, PaginatorTrait}; -use serde::Serialize; - -#[derive(Debug, Serialize)] -struct DashboardResponse { - users: u64, - products: u64, - categories: u64, - orders: u64, - audit_logs: u64, -} - -pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> { - ctx.config - .settings - .as_ref() - .and_then(|settings| settings.get("admin_email")) - .and_then(|email| email.as_str()) -} - -pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { - admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) -} - -pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result { - let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; - - if !is_admin(ctx, &user) { - return unauthorized("admin only"); - } - - Ok(user) -} - -#[debug_handler] -async fn dashboard(auth: auth::JWT, State(ctx): State) -> Result { - current_admin(auth, &ctx).await?; - - format::json(DashboardResponse { - users: users::Entity::find().count(&ctx.db).await?, - products: products::Entity::find().count(&ctx.db).await?, - categories: categories::Entity::find().count(&ctx.db).await?, - orders: orders::Entity::find().count(&ctx.db).await?, - audit_logs: audit_logs::Entity::find().count(&ctx.db).await?, - }) -} - -pub fn routes() -> Routes { - Routes::new() - .prefix("/api/admin") - .add("/dashboard", get(dashboard)) -} diff --git a/src/controllers/catalog.rs b/src/controllers/catalog.rs deleted file mode 100644 index 65eb11c..0000000 --- a/src/controllers/catalog.rs +++ /dev/null @@ -1,972 +0,0 @@ -use std::collections::HashMap; - -use crate::{ - controllers::{ - admin, - auth as auth_controller, - i18n::current_lang, - media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR}, - }, - models::{ - _entities::{categories, product_images, products}, - users, - }, -}; -use axum::extract::{DefaultBodyLimit, Multipart}; -use axum_extra::extract::cookie::CookieJar; -use loco_rs::prelude::*; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, - QueryOrder, QuerySelect, Set, -}; -use serde_json::json; - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -fn slugify(value: &str) -> String { - let mut slug = String::new(); - let mut last_was_dash = false; - for ch in value.chars().flat_map(char::to_lowercase) { - if ch.is_ascii_alphanumeric() { - slug.push(ch); - last_was_dash = false; - } else if !last_was_dash && !slug.is_empty() { - slug.push('-'); - last_was_dash = true; - } - } - slug.trim_matches('-').to_string() -} - -fn normalize_empty(value: Option) -> Option { - value.and_then(|value| { - let value = value.trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } - }) -} - -/// Parse a price typed in major units ("12", "12.5", "12.34") into integer -/// minor units (cents). Rejects negatives and more than two decimals. -pub(crate) fn parse_price_to_cents(value: &str) -> Result { - let value = value.trim().replace(',', "."); - let invalid = || Error::BadRequest("invalid price".to_string()); - let (whole, frac) = match value.split_once('.') { - Some((w, f)) => (w, f), - None => (value.as_str(), ""), - }; - if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() { - return Err(invalid()); - } - if !frac.chars().all(|c| c.is_ascii_digit()) { - return Err(invalid()); - } - let whole: i64 = whole.parse().map_err(|_| invalid())?; - let cents: i64 = match frac.len() { - 0 => 0, - 1 => frac.parse::().map_err(|_| invalid())? * 10, - _ => frac.parse().map_err(|_| invalid())?, - }; - Ok(whole * 100 + cents) -} - -/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`. -pub(crate) fn format_price(cents: i64) -> String { - format!("{}.{:02}", cents / 100, (cents % 100).abs()) -} - -async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { - let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { - return false; - }; - let Ok(jwt_config) = ctx.config.get_jwt_config() else { - return false; - }; - let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) - else { - return false; - }; - let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { - return false; - }; - admin::is_admin(ctx, &user) -} - -async fn unique_slug(base: &str, mut exists: F) -> Result -where - F: FnMut(String) -> Fut, - Fut: std::future::Future>, -{ - let base = if base.is_empty() { - "item".to_string() - } else { - base.to_string() - }; - let mut slug = base.clone(); - let mut suffix = 2; - while exists(slug.clone()).await? { - slug = format!("{base}-{suffix}"); - suffix += 1; - } - Ok(slug) -} - -/// Collected multipart form: text fields keyed by name, plus the raw bytes of -/// an `image` file part if one was uploaded (an empty file input is ignored). -struct MultipartForm { - fields: HashMap, - image: Option>, -} - -impl MultipartForm { - fn text(&self, key: &str) -> Option { - normalize_empty(self.fields.get(key).cloned()) - } - - fn checked(&self, key: &str) -> bool { - matches!(self.fields.get(key).map(String::as_str), Some("on" | "true" | "1")) - } -} - -async fn read_multipart_form(mut multipart: Multipart) -> Result { - let mut fields = HashMap::new(); - let mut image = None; - - while let Some(mut field) = multipart - .next_field() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))? - { - let name = field.name().unwrap_or("").to_string(); - if name == "image" { - let mut data = Vec::new(); - while let Some(chunk) = field - .chunk() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))? - { - data.extend_from_slice(&chunk); - if data.len() > IMAGE_MAX_BYTES { - return Err(Error::BadRequest(format!( - "image is larger than {} MB", - IMAGE_MAX_BYTES / 1024 / 1024 - ))); - } - } - if !data.is_empty() { - image = Some(data); - } - } else { - let value = field - .text() - .await - .map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?; - fields.insert(name, value); - } - } - - Ok(MultipartForm { fields, image }) -} - -async fn store_image(ctx: &AppContext, data: Vec) -> Result { - let extension = detect_image_extension(&data)?; - store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await -} - -async fn category_by_id(ctx: &AppContext, id: i32) -> Result { - categories::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound) -} - -async fn product_by_id(ctx: &AppContext, id: i32) -> Result { - products::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound) -} - -// --------------------------------------------------------------------------- -// Category hierarchy helpers (adjacency list via `parent_id`) -// --------------------------------------------------------------------------- - -/// Flatten the category forest into a depth-first ordered list of -/// `(category, depth)`, sorting siblings by position then name. `depth` is 0 -/// for top-level categories and increases by one per level — templates use it -/// to indent. -fn category_tree(categories: &[categories::Model]) -> Vec<(categories::Model, usize)> { - let mut children: HashMap, Vec<&categories::Model>> = HashMap::new(); - for category in categories { - children.entry(category.parent_id).or_default().push(category); - } - for siblings in children.values_mut() { - siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name))); - } - - fn walk( - parent: Option, - depth: usize, - children: &HashMap, Vec<&categories::Model>>, - out: &mut Vec<(categories::Model, usize)>, - ) { - if let Some(siblings) = children.get(&parent) { - for category in siblings { - out.push(((*category).clone(), depth)); - walk(Some(category.id), depth + 1, children, out); - } - } - } - - let mut out = Vec::new(); - walk(None, 0, &children, &mut out); - out -} - -/// Depth-ordered list of `{ name, slug, depth }` for the storefront sidebar, -/// rendered as an indented flat list. -fn category_sidebar_rows(categories: &[categories::Model]) -> Vec { - category_tree(categories) - .into_iter() - .map(|(category, depth)| { - json!({ "name": category.name, "slug": category.slug, "depth": depth }) - }) - .collect() -} - -/// Ids of every descendant of `root` (children, grandchildren, …), not -/// including `root` itself. -fn descendant_ids(categories: &[categories::Model], root: i32) -> std::collections::HashSet { - let mut set = std::collections::HashSet::new(); - let mut stack = vec![root]; - while let Some(id) = stack.pop() { - for child in categories.iter().filter(|c| c.parent_id == Some(id)) { - if set.insert(child.id) { - stack.push(child.id); - } - } - } - set -} - -/// Ancestor chain (root first … immediate parent last) for breadcrumbs. -fn ancestors(categories: &[categories::Model], start_parent: Option) -> Vec { - let mut chain = Vec::new(); - let mut current = start_parent; - while let Some(id) = current { - match categories.iter().find(|c| c.id == id) { - Some(category) => { - current = category.parent_id; - chain.push(category.clone()); - } - None => break, - } - } - chain.reverse(); - chain -} - -/// All categories, used as the source for tree building and validation. -async fn all_categories(ctx: &AppContext) -> Result> { - Ok(categories::Entity::find().all(&ctx.db).await?) -} - -async fn first_image(ctx: &AppContext, product_id: i32) -> Result> { - Ok(product_images::Entity::find() - .filter(product_images::Column::ProductId.eq(product_id)) - .order_by_asc(product_images::Column::Position) - .one(&ctx.db) - .await? - .map(|image| image.image_id)) -} - -/// Shape a product for templates: the model fields plus a formatted price, -/// its (optional) primary image filename and category name. -fn product_json( - product: &products::Model, - image: Option, - category_name: Option, -) -> serde_json::Value { - json!({ - "id": product.id, - "name": product.name, - "slug": product.slug, - "description": product.description, - "price": format_price(product.price_cents), - "currency": product.currency, - "sku": product.sku, - "stock": product.stock, - "published": product.published, - "image": image, - "category_name": category_name, - }) -} - -/// Latest published products (with primary image), shaped for templates. -/// Reused by the home page landing grid. -pub(crate) async fn featured_products( - ctx: &AppContext, - limit: u64, -) -> Result> { - let list = products::Entity::find() - .filter(products::Column::Published.eq(true)) - .order_by_desc(products::Column::PublishedAt) - .limit(limit) - .all(&ctx.db) - .await?; - let mut rows = Vec::new(); - for product in list { - let image = first_image(ctx, product.id).await?; - rows.push(product_json(&product, image, None)); - } - Ok(rows) -} - -// --------------------------------------------------------------------------- -// Admin: products -// --------------------------------------------------------------------------- - -#[debug_handler] -async fn admin_products( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let list = products::Entity::find() - .order_by_desc(products::Column::CreatedAt) - .all(&ctx.db) - .await?; - let mut rows = Vec::new(); - for product in list { - let image = first_image(&ctx, product.id).await?; - let category_name = match product.category_id { - Some(id) => category_by_id(&ctx, id).await.ok().map(|c| c.name), - None => None, - }; - rows.push(product_json(&product, image, category_name)); - } - format::view( - &v, - "admin/catalog/products.html", - json!({ "products": rows, "lang": current_lang(&jar) }), - ) -} - -async fn product_form_context(ctx: &AppContext, jar: &CookieJar) -> Result { - let categories = categories::Entity::find() - .order_by_asc(categories::Column::Position) - .order_by_asc(categories::Column::Name) - .all(&ctx.db) - .await?; - Ok(json!({ "categories": categories, "lang": current_lang(jar) })) -} - -#[debug_handler] -async fn admin_product_new( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let mut context = product_form_context(&ctx, &jar).await?; - context["product"] = serde_json::Value::Null; - format::view(&v, "admin/catalog/product_form.html", context) -} - -async fn parse_product_fields( - ctx: &AppContext, - form: &MultipartForm, - current_id: Option, -) -> Result<(String, String, Option, i64, String, Option, i32, Option, bool)> { - let name = form - .text("name") - .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?; - let price_cents = parse_price_to_cents( - form.text("price") - .ok_or_else(|| Error::BadRequest("price is required".to_string()))? - .as_str(), - )?; - let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string()); - let description = form.text("description"); - let sku = form.text("sku"); - let stock = form - .text("stock") - .and_then(|s| s.parse::().ok()) - .filter(|n| *n >= 0) - .unwrap_or(0); - let category_id = form.text("category_id").and_then(|s| s.parse::().ok()); - let published = form.checked("published"); - - let desired = form - .text("slug") - .map(|s| slugify(&s)) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| slugify(&name)); - let slug = unique_slug(&desired, |candidate| { - let ctx = ctx.clone(); - async move { - let mut query = - products::Entity::find().filter(products::Column::Slug.eq(candidate)); - if let Some(id) = current_id { - query = query.filter(products::Column::Id.ne(id)); - } - Ok(query.count(&ctx.db).await? > 0) - } - }) - .await?; - - Ok(( - name, slug, description, price_cents, currency, sku, stock, category_id, published, - )) -} - -#[debug_handler] -async fn admin_product_create( - auth: auth::JWT, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let form = read_multipart_form(multipart).await?; - let (name, slug, description, price_cents, currency, sku, stock, category_id, published) = - parse_product_fields(&ctx, &form, None).await?; - - let product = products::ActiveModel { - name: Set(name), - slug: Set(slug), - description: Set(description), - price_cents: Set(price_cents), - currency: Set(currency), - sku: Set(sku), - stock: Set(stock), - view_count: Set(0), - published: Set(published), - published_at: Set(published.then(|| chrono::Utc::now().into())), - category_id: Set(category_id), - ..Default::default() - } - .insert(&ctx.db) - .await?; - - if let Some(data) = form.image { - let filename = store_image(&ctx, data).await?; - product_images::ActiveModel { - product_id: Set(product.id), - image_id: Set(filename), - position: Set(0), - alt: Set(None), - ..Default::default() - } - .insert(&ctx.db) - .await?; - } - - format::redirect("/admin/catalog/products") -} - -#[debug_handler] -async fn admin_product_edit( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let product = product_by_id(&ctx, id).await?; - let image = first_image(&ctx, id).await?; - let mut context = product_form_context(&ctx, &jar).await?; - context["product"] = json!({ - "id": product.id, - "name": product.name, - "slug": product.slug, - "description": product.description, - "price": format_price(product.price_cents), - "currency": product.currency, - "sku": product.sku, - "stock": product.stock, - "published": product.published, - "category_id": product.category_id, - "image": image, - }); - format::view(&v, "admin/catalog/product_form.html", context) -} - -#[debug_handler] -async fn admin_product_update( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let existing = product_by_id(&ctx, id).await?; - let was_published = existing.published; - let form = read_multipart_form(multipart).await?; - let (name, slug, description, price_cents, currency, sku, stock, category_id, published) = - parse_product_fields(&ctx, &form, Some(id)).await?; - - let mut product = existing.into_active_model(); - product.name = Set(name); - product.slug = Set(slug); - product.description = Set(description); - product.price_cents = Set(price_cents); - product.currency = Set(currency); - product.sku = Set(sku); - product.stock = Set(stock); - product.category_id = Set(category_id); - product.published = Set(published); - if published && !was_published { - product.published_at = Set(Some(chrono::Utc::now().into())); - } else if !published { - product.published_at = Set(None); - } - product.update(&ctx.db).await?; - - if let Some(data) = form.image { - let filename = store_image(&ctx, data).await?; - let next_position = product_images::Entity::find() - .filter(product_images::Column::ProductId.eq(id)) - .count(&ctx.db) - .await? as i32; - product_images::ActiveModel { - product_id: Set(id), - image_id: Set(filename), - position: Set(next_position), - alt: Set(None), - ..Default::default() - } - .insert(&ctx.db) - .await?; - } - - format::redirect("/admin/catalog/products") -} - -#[debug_handler] -async fn admin_product_delete( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - product_by_id(&ctx, id).await?.delete(&ctx.db).await?; - format::redirect("/admin/catalog/products") -} - -// --------------------------------------------------------------------------- -// Admin: categories -// --------------------------------------------------------------------------- - -#[debug_handler] -async fn admin_categories( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let list = all_categories(&ctx).await?; - let mut rows = Vec::new(); - for (category, depth) in category_tree(&list) { - let product_count = products::Entity::find() - .filter(products::Column::CategoryId.eq(category.id)) - .count(&ctx.db) - .await?; - rows.push(json!({ "category": category, "depth": depth, "product_count": product_count })); - } - format::view( - &v, - "admin/catalog/categories.html", - json!({ "categories": rows, "lang": current_lang(&jar) }), - ) -} - -/// Build the parent-category dropdown options for the category form, as a -/// depth-ordered list of `{ id, name, depth }`. When editing, the category -/// itself and all of its descendants are excluded to keep the tree acyclic. -async fn category_form_context( - ctx: &AppContext, - jar: &CookieJar, - editing: Option, -) -> Result { - let all = all_categories(ctx).await?; - let blocked = match editing { - Some(id) => { - let mut set = descendant_ids(&all, id); - set.insert(id); - set - } - None => std::collections::HashSet::new(), - }; - let parents: Vec = category_tree(&all) - .into_iter() - .filter(|(category, _)| !blocked.contains(&category.id)) - .map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth })) - .collect(); - Ok(json!({ "parents": parents, "lang": current_lang(jar) })) -} - -#[debug_handler] -async fn admin_category_new( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let mut context = category_form_context(&ctx, &jar, None).await?; - context["category"] = serde_json::Value::Null; - format::view(&v, "admin/catalog/category_form.html", context) -} - -async fn parse_category_fields( - ctx: &AppContext, - form: &MultipartForm, - current_id: Option, -) -> Result<(String, String, Option, i32, bool, Option)> { - let name = form - .text("name") - .ok_or_else(|| Error::BadRequest("category name is required".to_string()))?; - let description = form.text("description"); - let position = form - .text("position") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let published = form.checked("published"); - - // Resolve the chosen parent, rejecting cycles: a category may not be its - // own parent nor be re-parented under one of its descendants. - let parent_id = match form.text("parent_id").and_then(|s| s.parse::().ok()) { - Some(parent_id) => { - categories::Entity::find_by_id(parent_id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?; - if let Some(id) = current_id { - if parent_id == id { - return Err(Error::BadRequest( - "a category cannot be its own parent".to_string(), - )); - } - if descendant_ids(&all_categories(ctx).await?, id).contains(&parent_id) { - return Err(Error::BadRequest( - "a category cannot be moved under its own descendant".to_string(), - )); - } - } - Some(parent_id) - } - None => None, - }; - - let desired = form - .text("slug") - .map(|s| slugify(&s)) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| slugify(&name)); - let slug = unique_slug(&desired, |candidate| { - let ctx = ctx.clone(); - async move { - let mut query = - categories::Entity::find().filter(categories::Column::Slug.eq(candidate)); - if let Some(id) = current_id { - query = query.filter(categories::Column::Id.ne(id)); - } - Ok(query.count(&ctx.db).await? > 0) - } - }) - .await?; - - Ok((name, slug, description, position, published, parent_id)) -} - -#[debug_handler] -async fn admin_category_create( - auth: auth::JWT, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let form = read_multipart_form(multipart).await?; - let (name, slug, description, position, published, parent_id) = - parse_category_fields(&ctx, &form, None).await?; - let image_id = match form.image { - Some(data) => Some(store_image(&ctx, data).await?), - None => None, - }; - - categories::ActiveModel { - name: Set(name), - slug: Set(slug), - description: Set(description), - image_id: Set(image_id), - position: Set(position), - published: Set(published), - parent_id: Set(parent_id), - ..Default::default() - } - .insert(&ctx.db) - .await?; - - format::redirect("/admin/catalog/categories") -} - -#[debug_handler] -async fn admin_category_edit( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let mut context = category_form_context(&ctx, &jar, Some(id)).await?; - context["category"] = json!(category_by_id(&ctx, id).await?); - format::view(&v, "admin/catalog/category_form.html", context) -} - -#[debug_handler] -async fn admin_category_update( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, - multipart: Multipart, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let existing = category_by_id(&ctx, id).await?; - let form = read_multipart_form(multipart).await?; - let (name, slug, description, position, published, parent_id) = - parse_category_fields(&ctx, &form, Some(id)).await?; - - let mut category = existing.into_active_model(); - category.name = Set(name); - category.slug = Set(slug); - category.description = Set(description); - category.position = Set(position); - category.published = Set(published); - category.parent_id = Set(parent_id); - if let Some(data) = form.image { - category.image_id = Set(Some(store_image(&ctx, data).await?)); - } - category.update(&ctx.db).await?; - - format::redirect("/admin/catalog/categories") -} - -#[debug_handler] -async fn admin_category_delete( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - category_by_id(&ctx, id).await?.delete(&ctx.db).await?; - format::redirect("/admin/catalog/categories") -} - -// --------------------------------------------------------------------------- -// Public storefront -// --------------------------------------------------------------------------- - -/// The site-wide category sidebar, loaded lazily via htmx by the base layout so -/// every page gets it without each handler having to supply category data. -#[debug_handler] -async fn category_sidebar( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let published = categories::Entity::find() - .filter(categories::Column::Published.eq(true)) - .all(&ctx.db) - .await?; - format::view( - &v, - "shop/_sidebar.html", - json!({ - "category_tree": category_sidebar_rows(&published), - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn shop_index( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let list = products::Entity::find() - .filter(products::Column::Published.eq(true)) - .order_by_desc(products::Column::PublishedAt) - .all(&ctx.db) - .await?; - let mut rows = Vec::new(); - for product in list { - let image = first_image(&ctx, product.id).await?; - rows.push(product_json(&product, image, None)); - } - - format::view( - &v, - "shop/index.html", - json!({ - "products": rows, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn shop_show( - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(slug): Path, - State(ctx): State, -) -> Result { - let product = products::Entity::find() - .filter(products::Column::Slug.eq(slug)) - .filter(products::Column::Published.eq(true)) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - - let mut active = product.clone().into_active_model(); - active.view_count = Set(product.view_count + 1); - let product = active.update(&ctx.db).await?; - - let images = product_images::Entity::find() - .filter(product_images::Column::ProductId.eq(product.id)) - .order_by_asc(product_images::Column::Position) - .all(&ctx.db) - .await?; - let category = match product.category_id { - Some(id) => category_by_id(&ctx, id).await.ok(), - None => None, - }; - - format::view( - &v, - "shop/show.html", - json!({ - "product": product_json(&product, None, category.as_ref().map(|c| c.name.clone())), - "images": images.iter().map(|i| i.image_id.clone()).collect::>(), - "category": category, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn shop_category( - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(slug): Path, - State(ctx): State, -) -> Result { - let published = categories::Entity::find() - .filter(categories::Column::Published.eq(true)) - .all(&ctx.db) - .await?; - let category = published - .iter() - .find(|c| c.slug == slug) - .cloned() - .ok_or_else(|| Error::NotFound)?; - - // Breadcrumb trail and the (published) direct children shown as sub-nav. - let breadcrumbs = ancestors(&published, category.parent_id); - let mut children: Vec = published - .iter() - .filter(|c| c.parent_id == Some(category.id)) - .cloned() - .collect(); - children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name))); - - // Products listed here span this category and all of its descendants, so a - // parent category is never empty just because its products live in leaves. - let mut category_ids: Vec = descendant_ids(&published, category.id) - .into_iter() - .collect(); - category_ids.push(category.id); - let list = products::Entity::find() - .filter(products::Column::CategoryId.is_in(category_ids)) - .filter(products::Column::Published.eq(true)) - .order_by_desc(products::Column::PublishedAt) - .all(&ctx.db) - .await?; - let mut rows = Vec::new(); - for product in list { - let image = first_image(&ctx, product.id).await?; - rows.push(product_json(&product, image, None)); - } - - format::view( - &v, - "shop/category.html", - json!({ - "category": category, - "breadcrumbs": breadcrumbs, - "children": children, - "products": rows, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -pub fn routes() -> Routes { - let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024); - Routes::new() - // public storefront - .add("/shop", get(shop_index)) - .add("/shop/{slug}", get(shop_show)) - .add("/category/{slug}", get(shop_category)) - .add("/partials/categories", get(category_sidebar)) - // admin products - .add("/admin/catalog/products", get(admin_products)) - .add("/admin/catalog/products/new", get(admin_product_new)) - .add( - "/admin/catalog/products", - post(admin_product_create).layer(image_limit.clone()), - ) - .add("/admin/catalog/products/{id}/edit", get(admin_product_edit)) - .add( - "/admin/catalog/products/{id}", - post(admin_product_update).layer(image_limit.clone()), - ) - .add( - "/admin/catalog/products/{id}/delete", - post(admin_product_delete), - ) - // admin categories - .add("/admin/catalog/categories", get(admin_categories)) - .add("/admin/catalog/categories/new", get(admin_category_new)) - .add( - "/admin/catalog/categories", - post(admin_category_create).layer(image_limit.clone()), - ) - .add( - "/admin/catalog/categories/{id}/edit", - get(admin_category_edit), - ) - .add( - "/admin/catalog/categories/{id}", - post(admin_category_update).layer(image_limit), - ) - .add( - "/admin/catalog/categories/{id}/delete", - post(admin_category_delete), - ) -} diff --git a/src/controllers/frontend.rs b/src/controllers/frontend.rs deleted file mode 100644 index 03266c9..0000000 --- a/src/controllers/frontend.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::{ - controllers::{admin, auth as auth_controller, i18n::current_lang}, - models::users::{self, LoginParams}, -}; -use axum_extra::extract::cookie::CookieJar; -use loco_rs::prelude::*; -use serde_json::json; - -async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool { - let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else { - return false; - }; - let Ok(jwt_config) = ctx.config.get_jwt_config() else { - return false; - }; - let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) - else { - return false; - }; - let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { - return false; - }; - - admin::is_admin(ctx, &user) -} - -#[debug_handler] -async fn home( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let products = crate::controllers::catalog::featured_products(&ctx, 8).await?; - - format::view( - &v, - "home/index.html", - json!({ - "products": products, - "logged_in_admin": logged_in_admin(&ctx, &jar).await, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_login_page( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - if logged_in_admin(&ctx, &jar).await { - return format::redirect("/admin/dashboard"); - } - - format::view( - &v, - "admin/login.html", - json!({ - "error": null, - "logged_in_admin": false, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_login( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, - Form(params): Form, -) -> Result { - let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { - return format::view( - &v, - "admin/login.html", - json!({ - "error": "Invalid credentials", - "logged_in_admin": false, - "lang": current_lang(&jar), - }), - ); - }; - - if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) { - return format::view( - &v, - "admin/login.html", - json!({ - "error": "Invalid credentials", - "logged_in_admin": false, - "lang": current_lang(&jar), - }), - ); - } - - let jwt_secret = ctx.config.get_jwt_config()?; - let token = user - .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) - .or_else(|_| unauthorized("unauthorized!"))?; - - format::render() - .cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])? - .redirect("/admin/dashboard") -} - -#[debug_handler] -async fn admin_logout() -> Result { - format::render() - .cookies(&[auth_controller::clear_auth_cookie()])? - .redirect("/admin/login") -} - -#[debug_handler] -async fn admin_home( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let admin_user = admin::current_admin(auth, &ctx).await?; - format::view( - &v, - "admin/index.html", - json!({ "admin": admin_user, "lang": current_lang(&jar) }), - ) -} - -pub fn routes() -> Routes { - Routes::new() - .add("/", get(home)) - .add("/admin/login", get(admin_login_page)) - .add("/admin/login", post(admin_login)) - .add("/admin/logout", post(admin_logout)) - .add("/admin", get(admin_login_page)) - .add("/admin/dashboard", get(admin_home)) -} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs deleted file mode 100644 index 6042e9c..0000000 --- a/src/controllers/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod admin; -pub mod auth; -pub mod cart; -pub mod catalog; -pub mod frontend; -pub mod i18n; -pub mod media; -pub mod orders; diff --git a/src/controllers/orders.rs b/src/controllers/orders.rs deleted file mode 100644 index 9ff388b..0000000 --- a/src/controllers/orders.rs +++ /dev/null @@ -1,455 +0,0 @@ -use crate::{ - controllers::{ - admin, - cart::{resolve_cart, CART_COOKIE}, - catalog::format_price, - i18n::current_lang, - }, - models::_entities::{order_items, orders, products, shipping_methods}, -}; -use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; -use loco_rs::prelude::*; -use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, -}; -use serde::Deserialize; -use serde_json::json; -use time::Duration as TimeDuration; -use uuid::Uuid; - -const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"]; -const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"]; - -#[derive(Debug, Deserialize)] -struct CheckoutForm { - email: String, - customer_name: String, - address: String, - city: String, - zip: String, - country: String, - note: Option, - payment_method: String, - carrier_code: String, - pickup_point_id: Option, - pickup_point_name: Option, -} - -fn setting<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> { - ctx.config - .settings - .as_ref() - .and_then(|settings| settings.get(key)) - .and_then(|value| value.as_str()) -} - -async fn enabled_shipping_methods(ctx: &AppContext) -> Result> { - Ok(shipping_methods::Entity::find() - .filter(shipping_methods::Column::Enabled.eq(true)) - .order_by_asc(shipping_methods::Column::Position) - .all(&ctx.db) - .await?) -} - -#[derive(Debug, Deserialize)] -struct StatusForm { - status: String, -} - -fn trimmed(value: &str) -> Option { - let value = value.trim(); - (!value.is_empty()).then(|| value.to_string()) -} - -fn generate_order_number() -> String { - let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase(); - format!("ORD-{suffix}") -} - -fn cleared_cart_cookie() -> Cookie<'static> { - Cookie::build((CART_COOKIE, "")) - .path("/") - .same_site(SameSite::Lax) - .max_age(TimeDuration::seconds(0)) - .build() -} - -#[debug_handler] -async fn checkout_page( - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; - if lines.is_empty() { - return format::redirect("/cart"); - } - let currency = lines - .first() - .and_then(|line| line["currency"].as_str()) - .unwrap_or("EUR") - .to_string(); - - let methods: Vec = enabled_shipping_methods(&ctx) - .await? - .iter() - .map(|m| { - json!({ - "code": m.code, - "name": m.name, - "price_cents": m.price_cents, - "price": format_price(m.price_cents), - "requires_pickup_point": m.requires_pickup_point, - }) - }) - .collect(); - - format::view( - &v, - "shop/checkout.html", - json!({ - "items": lines, - "subtotal": format_price(subtotal), - "subtotal_cents": subtotal, - "currency": currency, - "shipping_methods": methods, - "packeta_api_key": setting(&ctx, "packeta_api_key").unwrap_or(""), - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn place_order( - jar: CookieJar, - State(ctx): State, - Form(form): Form, -) -> Result { - let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?; - if valid.is_empty() { - return format::redirect("/cart"); - } - let email = trimmed(&form.email) - .ok_or_else(|| Error::BadRequest("email is required".to_string()))?; - - if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) { - return Err(Error::BadRequest("invalid payment method".to_string())); - } - - // Resolve the chosen carrier from the enabled methods (price is taken from - // the DB, never the form, so the customer can't pick their own fee). - let method = shipping_methods::Entity::find() - .filter(shipping_methods::Column::Code.eq(&form.carrier_code)) - .filter(shipping_methods::Column::Enabled.eq(true)) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?; - - let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point { - let id = form - .pickup_point_id - .as_deref() - .and_then(trimmed) - .ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?; - (Some(id), form.pickup_point_name.as_deref().and_then(trimmed)) - } else { - (None, None) - }; - - let txn = ctx.db.begin().await?; - - // Snapshot prices/names and decrement stock atomically. Re-checking stock - // inside the transaction guards against it selling out between cart and pay. - let mut subtotal: i64 = 0; - let mut currency = "EUR".to_string(); - let mut snapshots = Vec::new(); - for (product_id, qty) in &valid { - let product = products::Entity::find_by_id(*product_id) - .filter(products::Column::Published.eq(true)) - .one(&txn) - .await? - .ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?; - if product.stock < *qty { - return Err(Error::BadRequest(format!( - "not enough stock for {}", - product.name - ))); - } - currency = product.currency.clone(); - let line_total = product.price_cents * i64::from(*qty); - subtotal += line_total; - - let mut active = product.clone().into_active_model(); - active.stock = Set(product.stock - *qty); - active.update(&txn).await?; - - snapshots.push((product.id, product.name, product.price_cents, *qty)); - } - - let order = orders::ActiveModel { - order_number: Set(generate_order_number()), - email: Set(email), - customer_name: Set(trimmed(&form.customer_name)), - status: Set("pending".to_string()), - total_cents: Set(subtotal + method.price_cents), - currency: Set(currency), - address: Set(trimmed(&form.address)), - city: Set(trimmed(&form.city)), - zip: Set(trimmed(&form.zip)), - country: Set(trimmed(&form.country)), - note: Set(form.note.as_deref().and_then(trimmed)), - payment_method: Set(Some(form.payment_method.clone())), - carrier_code: Set(Some(method.code.clone())), - carrier_name: Set(Some(method.name.clone())), - shipping_cents: Set(method.price_cents), - pickup_point_id: Set(pickup_point_id), - pickup_point_name: Set(pickup_point_name), - ..Default::default() - } - .insert(&txn) - .await?; - - for (product_id, name, unit_price_cents, qty) in snapshots { - order_items::ActiveModel { - order_id: Set(order.id), - product_id: Set(Some(product_id)), - product_name: Set(name), - unit_price_cents: Set(unit_price_cents), - quantity: Set(qty), - ..Default::default() - } - .insert(&txn) - .await?; - } - - txn.commit().await?; - - format::render() - .cookies(&[cleared_cart_cookie()])? - .redirect(&format!("/orders/{}", order.order_number)) -} - -async fn order_with_items( - ctx: &AppContext, - order: &orders::Model, -) -> Result<(serde_json::Value, Vec)> { - let items = order_items::Entity::find() - .filter(order_items::Column::OrderId.eq(order.id)) - .all(&ctx.db) - .await?; - let items_json = items - .iter() - .map(|item| { - json!({ - "product_name": item.product_name, - "quantity": item.quantity, - "unit_price": format_price(item.unit_price_cents), - "line_total": format_price(item.unit_price_cents * i64::from(item.quantity)), - }) - }) - .collect(); - let order_json = json!({ - "id": order.id, - "order_number": order.order_number, - "email": order.email, - "customer_name": order.customer_name, - "status": order.status, - "subtotal": format_price(order.total_cents - order.shipping_cents), - "shipping": format_price(order.shipping_cents), - "total": format_price(order.total_cents), - "currency": order.currency, - "address": order.address, - "city": order.city, - "zip": order.zip, - "country": order.country, - "note": order.note, - "payment_method": order.payment_method, - "carrier_name": order.carrier_name, - "pickup_point_name": order.pickup_point_name, - // Numeric, sequential order id doubles as the bank variable symbol. - "variable_symbol": order.id, - "bank_iban": setting(ctx, "bank_iban").unwrap_or(""), - "bank_account_name": setting(ctx, "bank_account_name").unwrap_or(""), - "created_at": order.created_at.to_rfc3339(), - }); - Ok((order_json, items_json)) -} - -#[debug_handler] -async fn order_confirmation( - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(order_number): Path, - State(ctx): State, -) -> Result { - let order = orders::Entity::find() - .filter(orders::Column::OrderNumber.eq(order_number)) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - let (order_json, items) = order_with_items(&ctx, &order).await?; - - format::view( - &v, - "shop/order_confirmed.html", - json!({ "order": order_json, "items": items, "lang": current_lang(&jar) }), - ) -} - -// --------------------------------------------------------------------------- -// Admin -// --------------------------------------------------------------------------- - -#[debug_handler] -async fn admin_orders( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let list = orders::Entity::find() - .order_by_desc(orders::Column::CreatedAt) - .all(&ctx.db) - .await?; - let rows: Vec = list - .iter() - .map(|order| { - json!({ - "id": order.id, - "order_number": order.order_number, - "email": order.email, - "status": order.status, - "total": format_price(order.total_cents), - "currency": order.currency, - "created_at": order.created_at.to_rfc3339(), - }) - }) - .collect(); - format::view( - &v, - "admin/orders/index.html", - json!({ "orders": rows, "lang": current_lang(&jar) }), - ) -} - -#[debug_handler] -async fn admin_order_show( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - Path(id): Path, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let order = orders::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - let (order_json, items) = order_with_items(&ctx, &order).await?; - - format::view( - &v, - "admin/orders/show.html", - json!({ - "order": order_json, - "items": items, - "statuses": ORDER_STATUSES, - "lang": current_lang(&jar), - }), - ) -} - -#[debug_handler] -async fn admin_shipping( - auth: auth::JWT, - jar: CookieJar, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let methods = shipping_methods::Entity::find() - .order_by_asc(shipping_methods::Column::Position) - .all(&ctx.db) - .await?; - let rows: Vec = methods - .iter() - .map(|m| { - json!({ - "id": m.id, - "code": m.code, - "name": m.name, - "price": format_price(m.price_cents), - "requires_pickup_point": m.requires_pickup_point, - "enabled": m.enabled, - }) - }) - .collect(); - format::view( - &v, - "admin/shipping/index.html", - json!({ "methods": rows, "lang": current_lang(&jar) }), - ) -} - -#[derive(Debug, Deserialize)] -struct ShippingForm { - price: String, - enabled: Option, -} - -#[debug_handler] -async fn admin_shipping_update( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, - Form(form): Form, -) -> Result { - admin::current_admin(auth, &ctx).await?; - let method = shipping_methods::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - let mut active = method.into_active_model(); - active.price_cents = Set(crate::controllers::catalog::parse_price_to_cents(&form.price)?); - active.enabled = Set(matches!( - form.enabled.as_deref(), - Some("on" | "true" | "1") - )); - active.update(&ctx.db).await?; - format::redirect("/admin/shipping") -} - -#[debug_handler] -async fn admin_order_status( - auth: auth::JWT, - Path(id): Path, - State(ctx): State, - Form(form): Form, -) -> Result { - admin::current_admin(auth, &ctx).await?; - if !ORDER_STATUSES.contains(&form.status.as_str()) { - return Err(Error::BadRequest("invalid status".to_string())); - } - let order = orders::Entity::find_by_id(id) - .one(&ctx.db) - .await? - .ok_or_else(|| Error::NotFound)?; - let mut active = order.into_active_model(); - active.status = Set(form.status); - active.update(&ctx.db).await?; - - format::redirect(&format!("/admin/orders/{id}")) -} - -pub fn routes() -> Routes { - Routes::new() - .add("/checkout", get(checkout_page)) - .add("/checkout", post(place_order)) - .add("/orders/{order_number}", get(order_confirmation)) - .add("/admin/orders", get(admin_orders)) - .add("/admin/orders/{id}", get(admin_order_show)) - .add("/admin/orders/{id}/status", post(admin_order_status)) - .add("/admin/shipping", get(admin_shipping)) - .add("/admin/shipping/{id}", post(admin_shipping_update)) -} diff --git a/src/home/mod.rs b/src/home/mod.rs new file mode 100644 index 0000000..7faa885 --- /dev/null +++ b/src/home/mod.rs @@ -0,0 +1,30 @@ +//! Public landing page. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use serde_json::json; + +use crate::{i18n::current_lang, shared::guard, shop}; + +#[debug_handler] +async fn index( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let products = shop::featured_products(&ctx, 8).await?; + + format::view( + &v, + "home/index.html", + json!({ + "products": products, + "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "lang": current_lang(&jar), + }), + ) +} + +pub fn routes() -> Routes { + Routes::new().add("/", get(index)) +} diff --git a/src/controllers/i18n.rs b/src/i18n/mod.rs similarity index 100% rename from src/controllers/i18n.rs rename to src/i18n/mod.rs diff --git a/src/initializers/admin_seeder.rs b/src/initializers/admin_seeder.rs index b4d4620..810079e 100644 --- a/src/initializers/admin_seeder.rs +++ b/src/initializers/admin_seeder.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use loco_rs::prelude::*; -use crate::models::users::{self, RegisterParams}; +use crate::account::models::users::{self, RegisterParams}; pub struct AdminSeeder; diff --git a/src/lib.rs b/src/lib.rs index e8859fb..3a9f39f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,22 @@ pub mod app; -pub mod controllers; pub mod data; pub mod initializers; pub mod mailers; pub mod models; pub mod tasks; -pub mod views; pub mod workers; + +// Cross-cutting helpers shared by every feature. +pub mod shared; + +// Feature slices: each owns its routes, handlers, view-shaping and the model +// methods/services specific to it. Generated sea-orm entities stay shared in +// `models::_entities`. +pub mod account; +pub mod admin; +pub mod cart; +pub mod checkout; +pub mod home; +pub mod i18n; +pub mod media; +pub mod shop; diff --git a/src/mailers/auth.rs b/src/mailers/auth.rs index 88b949a..ca385dd 100644 --- a/src/mailers/auth.rs +++ b/src/mailers/auth.rs @@ -4,7 +4,7 @@ use loco_rs::prelude::*; use serde_json::json; -use crate::models::users; +use crate::account::models::users; static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); diff --git a/src/controllers/media.rs b/src/media/mod.rs similarity index 98% rename from src/controllers/media.rs rename to src/media/mod.rs index c242cb7..487a92d 100644 --- a/src/controllers/media.rs +++ b/src/media/mod.rs @@ -1,4 +1,4 @@ -use crate::controllers::admin; +use crate::shared::guard; use axum::{ body::Body, extract::{DefaultBodyLimit, Multipart}, @@ -127,7 +127,7 @@ async fn image_upload( State(ctx): State, multipart: Multipart, ) -> Result { - admin::current_admin(auth, &ctx).await?; + guard::current_admin(auth, &ctx).await?; let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?; let extension = detect_image_extension(&data)?; let size = data.len(); diff --git a/src/models/categories.rs b/src/models/categories.rs deleted file mode 100644 index 12e6cf8..0000000 --- a/src/models/categories.rs +++ /dev/null @@ -1,28 +0,0 @@ -use sea_orm::entity::prelude::*; -pub use super::_entities::categories::{ActiveModel, Model, Entity}; -pub type Categories = Entity; - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel { - async fn before_save(self, _db: &C, insert: bool) -> std::result::Result - where - C: ConnectionTrait, - { - if !insert && self.updated_at.is_unchanged() { - let mut this = self; - this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); - Ok(this) - } else { - Ok(self) - } - } -} - -// implement your read-oriented logic here -impl Model {} - -// implement your write-oriented logic here -impl ActiveModel {} - -// implement your custom finders, selectors oriented logic here -impl Entity {} diff --git a/src/models/mod.rs b/src/models/mod.rs index 9c1e287..7386ed0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,11 +1,7 @@ +//! Shared data layer: the sea-orm entities generated by `loco generate`. +//! +//! These structs cross-reference each other (relations) and are regenerated as +//! a unit, so they live here centrally. The hand-written model methods, +//! services and view-shaping that use them live in the feature slices +//! (`shop::models`, `checkout::models`, `account::models`, …). pub mod _entities; -pub mod audit_logs; -pub mod users; -pub mod categories; -pub mod products; -pub mod product_images; -pub mod product_tags; -pub mod product_product_tags; -pub mod orders; -pub mod order_items; -pub mod shipping_methods; diff --git a/src/models/orders.rs b/src/models/orders.rs deleted file mode 100644 index 3802429..0000000 --- a/src/models/orders.rs +++ /dev/null @@ -1,28 +0,0 @@ -use sea_orm::entity::prelude::*; -pub use super::_entities::orders::{ActiveModel, Model, Entity}; -pub type Orders = Entity; - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel { - async fn before_save(self, _db: &C, insert: bool) -> std::result::Result - where - C: ConnectionTrait, - { - if !insert && self.updated_at.is_unchanged() { - let mut this = self; - this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); - Ok(this) - } else { - Ok(self) - } - } -} - -// implement your read-oriented logic here -impl Model {} - -// implement your write-oriented logic here -impl ActiveModel {} - -// implement your custom finders, selectors oriented logic here -impl Entity {} diff --git a/src/models/product_images.rs b/src/models/product_images.rs deleted file mode 100644 index 8754380..0000000 --- a/src/models/product_images.rs +++ /dev/null @@ -1,28 +0,0 @@ -use sea_orm::entity::prelude::*; -pub use super::_entities::product_images::{ActiveModel, Model, Entity}; -pub type ProductImages = Entity; - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel { - async fn before_save(self, _db: &C, insert: bool) -> std::result::Result - where - C: ConnectionTrait, - { - if !insert && self.updated_at.is_unchanged() { - let mut this = self; - this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); - Ok(this) - } else { - Ok(self) - } - } -} - -// implement your read-oriented logic here -impl Model {} - -// implement your write-oriented logic here -impl ActiveModel {} - -// implement your custom finders, selectors oriented logic here -impl Entity {} diff --git a/src/shared/guard.rs b/src/shared/guard.rs new file mode 100644 index 0000000..aab8b4f --- /dev/null +++ b/src/shared/guard.rs @@ -0,0 +1,43 @@ +//! Shared admin-authorization helpers used by both admin and public controllers. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; + +use crate::account::models::users; +use crate::account::AUTH_COOKIE; +use crate::shared::settings; + +/// Is `user` the configured admin (settings.admin_email)? +pub fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { + settings::get(ctx, "admin_email") + .is_some_and(|email| user.email.eq_ignore_ascii_case(email)) +} + +/// Guard for admin handlers: requires a valid JWT whose user matches the +/// configured admin email. Returns the admin user, or an unauthorized error. +pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result { + let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; + if !is_admin(ctx, &user) { + return unauthorized("admin only"); + } + Ok(user) +} + +/// Soft check for public pages: does the request carry a valid admin auth +/// cookie? Never errors — used only to decide whether to show admin chrome. +pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool { + let Some(cookie) = jar.get(AUTH_COOKIE) else { + return false; + }; + let Ok(jwt_config) = ctx.config.get_jwt_config() else { + return false; + }; + let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value()) + else { + return false; + }; + let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else { + return false; + }; + is_admin(ctx, &user) +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs new file mode 100644 index 0000000..a687049 --- /dev/null +++ b/src/shared/mod.rs @@ -0,0 +1,6 @@ +//! Cross-cutting helpers used across feature slices. + +pub mod guard; +pub mod money; +pub mod settings; +pub mod slug; diff --git a/src/shared/money.rs b/src/shared/money.rs new file mode 100644 index 0000000..826b358 --- /dev/null +++ b/src/shared/money.rs @@ -0,0 +1,36 @@ +//! Money helpers. +//! +//! Prices are stored throughout the app as integer **minor units** (cents). +//! This module is the single place that converts between that storage form and +//! the human-facing decimal strings shown in forms and templates. + +use loco_rs::prelude::*; + +/// Parse a price typed in major units ("12", "12.5", "12.34") into integer +/// minor units (cents). Rejects negatives and more than two decimals. +pub fn parse_price_to_cents(value: &str) -> Result { + let value = value.trim().replace(',', "."); + let invalid = || Error::BadRequest("invalid price".to_string()); + let (whole, frac) = match value.split_once('.') { + Some((w, f)) => (w, f), + None => (value.as_str(), ""), + }; + if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() { + return Err(invalid()); + } + if !frac.chars().all(|c| c.is_ascii_digit()) { + return Err(invalid()); + } + let whole: i64 = whole.parse().map_err(|_| invalid())?; + let cents: i64 = match frac.len() { + 0 => 0, + 1 => frac.parse::().map_err(|_| invalid())? * 10, + _ => frac.parse().map_err(|_| invalid())?, + }; + Ok(whole * 100 + cents) +} + +/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`. +pub fn format_price(cents: i64) -> String { + format!("{}.{:02}", cents / 100, (cents % 100).abs()) +} diff --git a/src/shared/settings.rs b/src/shared/settings.rs new file mode 100644 index 0000000..e0965c0 --- /dev/null +++ b/src/shared/settings.rs @@ -0,0 +1,13 @@ +//! Typed access to the free-form `settings.*` map from the loaded config. + +use loco_rs::prelude::*; + +/// Look up a string-valued `settings.` entry, returning `None` if config +/// has no settings map, the key is missing, or the value is not a string. +pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> { + ctx.config + .settings + .as_ref() + .and_then(|settings| settings.get(key)) + .and_then(|value| value.as_str()) +} diff --git a/src/shared/slug.rs b/src/shared/slug.rs new file mode 100644 index 0000000..d43aa77 --- /dev/null +++ b/src/shared/slug.rs @@ -0,0 +1,38 @@ +//! URL slug helpers shared by the catalog admin (products and categories). + +use loco_rs::prelude::*; + +/// Lowercase a string and collapse every run of non-alphanumeric characters +/// into a single dash, trimming dashes from the ends. +pub fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in value.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + slug.trim_matches('-').to_string() +} + +/// Find the first slug that does not already exist, appending `-2`, `-3`, … to +/// `base` until `exists` reports the candidate as free. An empty `base` falls +/// back to `"item"`. +pub async fn unique_slug(base: &str, mut exists: F) -> Result +where + F: FnMut(String) -> Fut, + Fut: std::future::Future>, +{ + let base = if base.is_empty() { "item" } else { base }; + let mut slug = base.to_string(); + let mut suffix = 2; + while exists(slug.clone()).await? { + slug = format!("{base}-{suffix}"); + suffix += 1; + } + Ok(slug) +} diff --git a/src/shop/mod.rs b/src/shop/mod.rs new file mode 100644 index 0000000..b5166ea --- /dev/null +++ b/src/shop/mod.rs @@ -0,0 +1,176 @@ +//! Public storefront: product listings, product detail, category pages and the +//! lazily-loaded category sidebar. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set}; +use serde_json::json; + +pub mod models; +pub mod view; + +use crate::{ + i18n::current_lang, + shared::guard, + shop::models::{categories, product_images, products}, +}; + +/// Shape a list of products into card rows, loading each one's primary image. +async fn product_rows(ctx: &AppContext, list: Vec) -> Result> { + let mut rows = Vec::with_capacity(list.len()); + for product in list { + let image = product_images::first_for(ctx, product.id).await?; + rows.push(view::product_card(&product, image, None)); + } + Ok(rows) +} + +/// Latest published products (with primary image), shaped for templates. Reused +/// by the home-page landing grid. +pub(crate) async fn featured_products( + ctx: &AppContext, + limit: u64, +) -> Result> { + let list = products::Entity::find() + .filter(products::Column::Published.eq(true)) + .order_by_desc(products::Column::PublishedAt) + .limit(limit) + .all(&ctx.db) + .await?; + product_rows(ctx, list).await +} + +/// The site-wide category sidebar, loaded lazily via htmx by the base layout so +/// every page gets it without each handler having to supply category data. +#[debug_handler] +async fn category_sidebar( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let published = categories::published(&ctx).await?; + format::view( + &v, + "shop/_sidebar.html", + json!({ + "category_tree": view::sidebar_rows(&categories::tree(&published)), + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn index( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let list = products::Entity::find() + .filter(products::Column::Published.eq(true)) + .order_by_desc(products::Column::PublishedAt) + .all(&ctx.db) + .await?; + + format::view( + &v, + "shop/index.html", + json!({ + "products": product_rows(&ctx, list).await?, + "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn show( + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(slug): Path, + State(ctx): State, +) -> Result { + let product = products::Entity::find() + .filter(products::Column::Slug.eq(slug)) + .filter(products::Column::Published.eq(true)) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + + let mut active = product.clone().into_active_model(); + active.view_count = Set(product.view_count + 1); + let product = active.update(&ctx.db).await?; + + let images = product_images::Entity::find() + .filter(product_images::Column::ProductId.eq(product.id)) + .order_by_asc(product_images::Column::Position) + .all(&ctx.db) + .await?; + let category = match product.category_id { + Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?, + None => None, + }; + + format::view( + &v, + "shop/show.html", + json!({ + "product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())), + "images": images.iter().map(|i| i.image_id.clone()).collect::>(), + "category": category, + "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn category( + jar: CookieJar, + ViewEngine(v): ViewEngine, + Path(slug): Path, + State(ctx): State, +) -> Result { + let published = categories::published(&ctx).await?; + let category = published + .iter() + .find(|c| c.slug == slug) + .cloned() + .ok_or_else(|| Error::NotFound)?; + + let breadcrumbs = categories::ancestors(&published, category.parent_id); + let children = categories::children_of(&published, category.id); + + // Products listed here span this category and all of its descendants, so a + // parent category is never empty just because its products live in leaves. + let mut category_ids: Vec = categories::descendant_ids(&published, category.id) + .into_iter() + .collect(); + category_ids.push(category.id); + let list = products::Entity::find() + .filter(products::Column::CategoryId.is_in(category_ids)) + .filter(products::Column::Published.eq(true)) + .order_by_desc(products::Column::PublishedAt) + .all(&ctx.db) + .await?; + + format::view( + &v, + "shop/category.html", + json!({ + "category": category, + "breadcrumbs": breadcrumbs, + "children": children, + "products": product_rows(&ctx, list).await?, + "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "lang": current_lang(&jar), + }), + ) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/shop", get(index)) + .add("/shop/{slug}", get(show)) + .add("/category/{slug}", get(category)) + .add("/partials/categories", get(category_sidebar)) +} diff --git a/src/shop/models/categories.rs b/src/shop/models/categories.rs new file mode 100644 index 0000000..3dfc31d --- /dev/null +++ b/src/shop/models/categories.rs @@ -0,0 +1,125 @@ +use std::collections::{HashMap, HashSet}; + +use loco_rs::prelude::*; +use sea_orm::entity::prelude::*; + +pub use crate::models::_entities::categories::{ActiveModel, Column, Entity, Model}; +pub type Categories = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/// Every category, the source for tree building and validation. +pub async fn all(ctx: &AppContext) -> Result> { + Ok(Entity::find().all(&ctx.db).await?) +} + +/// Only published categories, for the storefront. +pub async fn published(ctx: &AppContext) -> Result> { + Ok(Entity::find() + .filter(Column::Published.eq(true)) + .all(&ctx.db) + .await?) +} + +// --------------------------------------------------------------------------- +// Hierarchy (adjacency list via `parent_id`) +// --------------------------------------------------------------------------- + +/// Flatten the category forest into a depth-first ordered list of +/// `(category, depth)`, sorting siblings by position then name. `depth` is 0 +/// for top-level categories and increases by one per level — templates use it +/// to indent. +pub fn tree(categories: &[Model]) -> Vec<(Model, usize)> { + let mut children: HashMap, Vec<&Model>> = HashMap::new(); + for category in categories { + children.entry(category.parent_id).or_default().push(category); + } + for siblings in children.values_mut() { + siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name))); + } + + fn walk( + parent: Option, + depth: usize, + children: &HashMap, Vec<&Model>>, + out: &mut Vec<(Model, usize)>, + ) { + if let Some(siblings) = children.get(&parent) { + for category in siblings { + out.push(((*category).clone(), depth)); + walk(Some(category.id), depth + 1, children, out); + } + } + } + + let mut out = Vec::new(); + walk(None, 0, &children, &mut out); + out +} + +/// Ids of every descendant of `root` (children, grandchildren, …), not +/// including `root` itself. +pub fn descendant_ids(categories: &[Model], root: i32) -> HashSet { + let mut set = HashSet::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + for child in categories.iter().filter(|c| c.parent_id == Some(id)) { + if set.insert(child.id) { + stack.push(child.id); + } + } + } + set +} + +/// Ancestor chain (root first … immediate parent last) for breadcrumbs. +pub fn ancestors(categories: &[Model], start_parent: Option) -> Vec { + let mut chain = Vec::new(); + let mut current = start_parent; + while let Some(id) = current { + match categories.iter().find(|c| c.id == id) { + Some(category) => { + current = category.parent_id; + chain.push(category.clone()); + } + None => break, + } + } + chain.reverse(); + chain +} + +/// Published direct children of `parent_id`, sorted for sub-navigation. +pub fn children_of(categories: &[Model], parent_id: i32) -> Vec { + let mut children: Vec = categories + .iter() + .filter(|c| c.parent_id == Some(parent_id)) + .cloned() + .collect(); + children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name))); + children +} diff --git a/src/shop/models/mod.rs b/src/shop/models/mod.rs new file mode 100644 index 0000000..c9071d3 --- /dev/null +++ b/src/shop/models/mod.rs @@ -0,0 +1,5 @@ +pub mod categories; +pub mod product_images; +pub mod product_product_tags; +pub mod product_tags; +pub mod products; diff --git a/src/shop/models/product_images.rs b/src/shop/models/product_images.rs new file mode 100644 index 0000000..e701593 --- /dev/null +++ b/src/shop/models/product_images.rs @@ -0,0 +1,47 @@ +use loco_rs::prelude::*; +use sea_orm::entity::prelude::*; +use sea_orm::QueryOrder; + +pub use crate::models::_entities::product_images::{ActiveModel, Column, Entity, Model}; +pub type ProductImages = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} + +/// Filename of a product's primary (lowest-position) image, if any. +pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result> { + Ok(Entity::find() + .filter(Column::ProductId.eq(product_id)) + .order_by_asc(Column::Position) + .one(&ctx.db) + .await? + .map(|image| image.image_id)) +} + +/// Number of images already attached to a product, used to position new uploads. +pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result { + use sea_orm::PaginatorTrait; + Ok(Entity::find() + .filter(Column::ProductId.eq(product_id)) + .count(&ctx.db) + .await? as i32) +} diff --git a/src/models/product_product_tags.rs b/src/shop/models/product_product_tags.rs similarity index 85% rename from src/models/product_product_tags.rs rename to src/shop/models/product_product_tags.rs index 0a62737..3749ab4 100644 --- a/src/models/product_product_tags.rs +++ b/src/shop/models/product_product_tags.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -pub use super::_entities::product_product_tags::{ActiveModel, Model, Entity}; +pub use crate::models::_entities::product_product_tags::{ActiveModel, Model, Entity}; pub type ProductProductTags = Entity; #[async_trait::async_trait] diff --git a/src/models/product_tags.rs b/src/shop/models/product_tags.rs similarity index 90% rename from src/models/product_tags.rs rename to src/shop/models/product_tags.rs index aca2784..93b2d2a 100644 --- a/src/models/product_tags.rs +++ b/src/shop/models/product_tags.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -pub use super::_entities::product_tags::{ActiveModel, Model, Entity}; +pub use crate::models::_entities::product_tags::{ActiveModel, Model, Entity}; pub type ProductTags = Entity; #[async_trait::async_trait] diff --git a/src/models/products.rs b/src/shop/models/products.rs similarity index 89% rename from src/models/products.rs rename to src/shop/models/products.rs index 264e879..c311057 100644 --- a/src/models/products.rs +++ b/src/shop/models/products.rs @@ -1,5 +1,5 @@ use sea_orm::entity::prelude::*; -pub use super::_entities::products::{ActiveModel, Model, Entity}; +pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model}; pub type Products = Entity; #[async_trait::async_trait] diff --git a/src/shop/view.rs b/src/shop/view.rs new file mode 100644 index 0000000..aae0fa2 --- /dev/null +++ b/src/shop/view.rs @@ -0,0 +1,56 @@ +//! JSON shaping for storefront and catalog-admin templates. + +use serde_json::{json, Value}; + +use crate::models::_entities::{categories, products}; +use crate::shared::money::format_price; + +/// Card/list shape for a product: model fields plus a formatted price, its +/// optional primary image filename and category name. +pub fn product_card( + product: &products::Model, + image: Option, + category_name: Option, +) -> Value { + json!({ + "id": product.id, + "name": product.name, + "slug": product.slug, + "description": product.description, + "price": format_price(product.price_cents), + "currency": product.currency, + "sku": product.sku, + "stock": product.stock, + "published": product.published, + "image": image, + "category_name": category_name, + }) +} + +/// Shape used to pre-fill the admin product form (exposes `category_id` rather +/// than a resolved name, and the current primary image). +pub fn product_form(product: &products::Model, image: Option) -> Value { + json!({ + "id": product.id, + "name": product.name, + "slug": product.slug, + "description": product.description, + "price": format_price(product.price_cents), + "currency": product.currency, + "sku": product.sku, + "stock": product.stock, + "published": product.published, + "category_id": product.category_id, + "image": image, + }) +} + +/// Depth-ordered `{ name, slug, depth }` rows for the storefront sidebar, +/// rendered as an indented flat list. +pub fn sidebar_rows(tree: &[(categories::Model, usize)]) -> Vec { + tree.iter() + .map(|(category, depth)| { + json!({ "name": category.name, "slug": category.slug, "depth": depth }) + }) + .collect() +} diff --git a/src/views/mod.rs b/src/views/mod.rs deleted file mode 100644 index 0e4a05d..0000000 --- a/src/views/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod auth; diff --git a/tests/models/users.rs b/tests/models/users.rs index d715e45..6584ed9 100644 --- a/tests/models/users.rs +++ b/tests/models/users.rs @@ -4,8 +4,8 @@ use loco_rs::testing::prelude::*; use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel}; use serial_test::serial; use gitara_web::{ + account::models::users::{self, Model, RegisterParams}, app::App, - models::users::{self, Model, RegisterParams}, }; macro_rules! configure_insta { diff --git a/tests/requests/auth.rs b/tests/requests/auth.rs index ff54836..7e318c4 100644 --- a/tests/requests/auth.rs +++ b/tests/requests/auth.rs @@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings}; use loco_rs::testing::prelude::*; use rstest::rstest; use serial_test::serial; -use gitara_web::{app::App, models::users}; +use gitara_web::{account::models::users, app::App}; use super::prepare_data; diff --git a/tests/requests/prepare_data.rs b/tests/requests/prepare_data.rs index 57174f7..edf8b37 100644 --- a/tests/requests/prepare_data.rs +++ b/tests/requests/prepare_data.rs @@ -1,6 +1,6 @@ use axum::http::{HeaderName, HeaderValue}; use loco_rs::{app::AppContext, TestServer}; -use gitara_web::{models::users, views::auth::LoginResponse}; +use gitara_web::{account::models::users, account::view::LoginResponse}; const USER_EMAIL: &str = "test@loco.com"; const USER_PASSWORD: &str = "1234";