eshop
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-16 16:35:50 +02:00
parent c4f60dd8d7
commit baf7522273
87 changed files with 3270 additions and 3483 deletions

View File

@@ -60,16 +60,16 @@ impl Hooks for App {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::auth::routes())
.add_route(controllers::admin::routes())
.add_route(controllers::blog::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::pages::routes())
.add_route(controllers::frontend::routes())
}
async fn after_context(ctx: AppContext) -> Result<AppContext> {
let upload_root = crate::controllers::media::uploads_root(&ctx.config)?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::AUDIO_STORAGE_DIR)).await?;
tokio::fs::create_dir_all(upload_root.join(controllers::media::IMAGE_STORAGE_DIR)).await?;
let driver = storage::drivers::local::new_with_prefix(&upload_root)?;

View File

@@ -1,5 +1,5 @@
use crate::models::{
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
_entities::{audit_logs, categories, orders, products, users},
users as users_model,
};
use loco_rs::prelude::*;
@@ -9,9 +9,9 @@ use serde::Serialize;
#[derive(Debug, Serialize)]
struct DashboardResponse {
users: u64,
blog_articles: u64,
audio_albums: u64,
audio_tracks: u64,
products: u64,
categories: u64,
orders: u64,
audit_logs: u64,
}
@@ -43,9 +43,9 @@ async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Res
format::json(DashboardResponse {
users: users::Entity::find().count(&ctx.db).await?,
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
audio_tracks: audio_tracks::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?,
})
}

View File

@@ -1,245 +0,0 @@
use crate::{controllers::admin, models::_entities::blog_articles};
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct ArticleParams {
title: String,
content: String,
excerpt: Option<String>,
published: Option<bool>,
featured_image_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct ArticleResponse {
id: Uuid,
title: String,
slug: String,
content: String,
excerpt: Option<String>,
published: bool,
author_id: i32,
featured_image_id: Option<String>,
view_count: i32,
created_at: chrono::DateTime<chrono::FixedOffset>,
updated_at: chrono::DateTime<chrono::FixedOffset>,
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
}
#[derive(Debug, Serialize)]
struct ArticleListResponse {
articles: Vec<ArticleResponse>,
}
impl From<blog_articles::Model> for ArticleResponse {
fn from(article: blog_articles::Model) -> Self {
Self {
id: article.id,
title: article.title,
slug: article.slug,
content: article.content,
excerpt: article.excerpt,
published: article.published,
author_id: article.author_id,
featured_image_id: article.featured_image_id,
view_count: article.view_count,
created_at: article.created_at,
updated_at: article.updated_at,
published_at: article.published_at,
}
}
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.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;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn public_index(State(ctx): State<AppContext>) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn public_show(Path(slug): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_index(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?
.into_iter()
.map(ArticleResponse::from)
.collect();
format::json(ArticleListResponse { articles })
}
#[debug_handler]
async fn admin_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = params.published.unwrap_or(false);
let article = blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(params.excerpt),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(params.featured_image_id),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Json(params): Json<ArticleParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = find_article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = params.published.unwrap_or(was_published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(params.excerpt);
article.published = Set(published);
article.featured_image_id = Set(params.featured_image_id);
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let article = find_article_by_id(&ctx, id).await?;
article.delete(&ctx.db).await?;
format::json(())
}
#[debug_handler]
async fn admin_publish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(true);
article.published_at = Set(published_at_for(true));
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
#[debug_handler]
async fn admin_unpublish(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
article.published = Set(false);
article.published_at = Set(None);
let article = article.update(&ctx.db).await?;
format::json(ArticleResponse::from(article))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/blog", get(public_index))
.add("/blog/{slug}", get(public_show))
.add("/admin/blog/articles", get(admin_index))
.add("/admin/blog/articles", post(admin_create))
.add("/admin/blog/articles/{id}", put(admin_update))
.add("/admin/blog/articles/{id}", delete(admin_delete))
.add("/admin/blog/articles/{id}/publish", post(admin_publish))
.add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish))
}

203
src/controllers/cart.rs Normal file
View File

@@ -0,0 +1,203 @@
use crate::{
controllers::{catalog::format_price, i18n::current_lang},
models::_entities::products,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
pub(crate) const CART_COOKIE: &str = "cart";
const CART_MAX_AGE_DAYS: i64 = 30;
#[derive(Debug, Deserialize)]
struct AddForm {
product_id: i32,
quantity: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct UpdateForm {
product_id: i32,
quantity: i32,
}
#[derive(Debug, Deserialize)]
struct RemoveForm {
product_id: i32,
}
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
/// pairs, silently dropping malformed or non-positive entries.
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
let Some(cookie) = jar.get(CART_COOKIE) else {
return Vec::new();
};
cookie
.value()
.split(',')
.filter_map(|entry| {
let (id, qty) = entry.split_once(':')?;
let id = id.trim().parse::<i32>().ok()?;
let qty = qty.trim().parse::<i32>().ok()?;
(qty > 0).then_some((id, qty))
})
.collect()
}
fn serialize_cart(items: &[(i32, i32)]) -> String {
items
.iter()
.map(|(id, qty)| format!("{id}:{qty}"))
.collect::<Vec<_>>()
.join(",")
}
fn cart_cookie(value: String) -> Cookie<'static> {
Cookie::build((CART_COOKIE, value))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
.build()
}
/// Look up a published product, returning its current stock cap.
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
Ok(products::Entity::find_by_id(id)
.filter(products::Column::Published.eq(true))
.one(&ctx.db)
.await?)
}
#[debug_handler]
async fn add(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<AddForm>,
) -> Result<Response> {
let Some(product) = published_product(&ctx, form.product_id).await? else {
return Err(Error::NotFound);
};
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
entry.1 = (entry.1 + add_qty).min(product.stock);
} else {
items.push((product.id, add_qty.min(product.stock)));
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn update(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<UpdateForm>,
) -> Result<Response> {
let stock = published_product(&ctx, form.product_id)
.await?
.map(|p| p.stock)
.unwrap_or(0);
let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
entry.1 = clamped;
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
let mut items = parse_cart(&jar);
items.retain(|(id, _)| *id != form.product_id);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
/// Resolve the cart cookie into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Returns the
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut total: i64 = 0;
for (id, qty) in parse_cart(jar) {
let Some(product) = published_product(ctx, id).await? else {
continue;
};
let qty = qty.clamp(0, product.stock);
if qty == 0 {
continue;
}
let line_total = product.price_cents * i64::from(qty);
total += line_total;
valid.push((product.id, qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(product.price_cents),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,
"line_total": format_price(line_total),
}));
}
Ok((lines, valid, total))
}
#[debug_handler]
async fn show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/cart.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"lang": current_lang(&jar),
}),
)?;
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
}
pub fn routes() -> Routes {
Routes::new()
.add("/cart", get(show))
.add("/cart/add", post(add))
.add("/cart/update", post(update))
.add("/cart/remove", post(remove))
}

807
src/controllers/catalog.rs Normal file
View File

@@ -0,0 +1,807 @@
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<String>) -> Option<String> {
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.
fn parse_price_to_cents(value: &str) -> Result<i64> {
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::<i64>().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<F, Fut>(base: &str, mut exists: F) -> Result<String>
where
F: FnMut(String) -> Fut,
Fut: std::future::Future<Output = Result<bool>>,
{
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<String, String>,
image: Option<Vec<u8>>,
}
impl MultipartForm {
fn text(&self, key: &str) -> Option<String> {
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<MultipartForm> {
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<u8>) -> Result<String> {
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::Model> {
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::Model> {
products::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
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<String>,
category_name: Option<String>,
) -> 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<Vec<serde_json::Value>> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<serde_json::Value> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<i32>,
) -> Result<(String, String, Option<String>, i64, String, Option<String>, i32, Option<i32>, 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::<i32>().ok())
.filter(|n| *n >= 0)
.unwrap_or(0);
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().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<AppContext>,
multipart: Multipart,
) -> Result<Response> {
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<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
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<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let list = categories::Entity::find()
.order_by_asc(categories::Column::Position)
.order_by_asc(categories::Column::Name)
.all(&ctx.db)
.await?;
let mut rows = Vec::new();
for category in list {
let product_count = products::Entity::find()
.filter(products::Column::CategoryId.eq(category.id))
.count(&ctx.db)
.await?;
rows.push(json!({ "category": category, "product_count": product_count }));
}
format::view(
&v,
"admin/catalog/categories.html",
json!({ "categories": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_category_new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/catalog/category_form.html",
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
)
}
async fn parse_category_fields(
ctx: &AppContext,
form: &MultipartForm,
current_id: Option<i32>,
) -> Result<(String, String, Option<String>, i32, bool)> {
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::<i32>().ok())
.unwrap_or(0);
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 =
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))
}
#[debug_handler]
async fn admin_category_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let (name, slug, description, position, published) =
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),
..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<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/catalog/category_form.html",
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_category_update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
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) =
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);
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<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/categories")
}
// ---------------------------------------------------------------------------
// Public storefront
// ---------------------------------------------------------------------------
#[debug_handler]
async fn shop_index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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));
}
let categories = categories::Entity::find()
.filter(categories::Column::Published.eq(true))
.order_by_asc(categories::Column::Position)
.all(&ctx.db)
.await?;
format::view(
&v,
"shop/index.html",
json!({
"products": rows,
"categories": categories,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn shop_show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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::<Vec<_>>(),
"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<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let category = categories::Entity::find()
.filter(categories::Column::Slug.eq(slug))
.filter(categories::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let list = products::Entity::find()
.filter(products::Column::CategoryId.eq(category.id))
.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,
"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))
// 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),
)
}

View File

@@ -1,95 +1,10 @@
use crate::{
controllers::{admin, auth as auth_controller, i18n::current_lang},
models::{
_entities::{audio_albums, audio_tracks, blog_articles, site_pages},
users::{self, LoginParams},
},
models::users::{self, LoginParams},
};
use axum_extra::extract::cookie::CookieJar;
use chrono::Utc;
use loco_rs::prelude::*;
use sea_orm::{
sea_query::Expr, ActiveModelTrait, ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct ArticleForm {
title: String,
content: String,
excerpt: Option<String>,
published: Option<String>,
featured_image_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AboutForm {
title: String,
content: String,
}
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in title.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;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
Uuid::new_v4().to_string()
} else {
slug
}
}
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
published.then(|| Utc::now().into())
}
fn is_checked(value: &Option<String>) -> bool {
value
.as_deref()
.is_some_and(|value| value == "on" || value == "true")
}
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
None
} else {
Some(value)
}
})
}
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
blog_articles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
@@ -115,108 +30,13 @@ async fn home(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.limit(5)
.all(&ctx.db)
.await?;
// A random published song to suggest on the landing page.
let featured_track = audio_tracks::Entity::find()
.filter(audio_tracks::Column::Published.eq(true))
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
// A random published album, never the one the suggested song belongs to.
let mut album_query =
audio_albums::Entity::find().filter(audio_albums::Column::Published.eq(true));
if let Some(album_id) = featured_track.as_ref().and_then(|track| track.album_id) {
album_query = album_query.filter(audio_albums::Column::Id.ne(album_id));
}
let featured_album = album_query
.order_by(Expr::cust("RANDOM()"), Order::Asc)
.one(&ctx.db)
.await?;
let products = crate::controllers::catalog::featured_products(&ctx, 8).await?;
format::view(
&v,
"home/index.html",
json!({
"articles": articles,
"featured_track": featured_track,
"featured_album": featured_album,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn about(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
format::view(
&v,
"pages/about.html",
json!({
"page": about_page(&ctx).await?,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn blog_index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let articles = blog_articles::Entity::find()
.filter(blog_articles::Column::Published.eq(true))
.order_by_desc(blog_articles::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"blog/index.html",
json!({
"articles": articles,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn blog_show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let article = blog_articles::Entity::find()
.filter(blog_articles::Column::Slug.eq(slug))
.filter(blog_articles::Column::Published.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = article.into_active_model();
let next_count = active.view_count.as_ref().to_owned() + 1;
active.view_count = Set(next_count);
let article = active.update(&ctx.db).await?;
format::view(
&v,
"blog/show.html",
json!({
"article": article,
"products": products,
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
@@ -307,173 +127,12 @@ async fn admin_home(
)
}
#[debug_handler]
async fn admin_about(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/about.html",
json!({ "page": about_page(&ctx).await?, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_about_update(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<AboutForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let mut page = about_page(&ctx).await?.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?;
format::redirect("/admin/about")
}
#[debug_handler]
async fn admin_articles(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let articles = blog_articles::Entity::find()
.order_by_desc(blog_articles::Column::CreatedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"admin/blog/index.html",
json!({ "articles": articles, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/new.html",
json!({ "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
let admin_user = admin::current_admin(auth, &ctx).await?;
let published = is_checked(&params.published);
blog_articles::ActiveModel {
id: Set(Uuid::new_v4()),
title: Set(params.title.clone()),
slug: Set(slugify(&params.title)),
content: Set(params.content),
excerpt: Set(normalize_empty(params.excerpt)),
published: Set(published),
author_id: Set(admin_user.id),
featured_image_id: Set(normalize_empty(params.featured_image_id)),
view_count: Set(0),
published_at: Set(published_at_for(published)),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/blog/edit.html",
json!({ "article": article_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn admin_article_update(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
Form(params): Form<ArticleForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let existing = article_by_id(&ctx, id).await?;
let was_published = existing.published;
let published = is_checked(&params.published);
let mut article = existing.into_active_model();
article.title = Set(params.title.clone());
article.slug = Set(slugify(&params.title));
article.content = Set(params.content);
article.excerpt = Set(normalize_empty(params.excerpt));
article.published = Set(published);
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
if published && !was_published {
article.published_at = Set(published_at_for(true));
} else if !published {
article.published_at = Set(None);
}
article.update(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
#[debug_handler]
async fn admin_article_delete(
auth: auth::JWT,
Path(id): Path<Uuid>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/blog/articles")
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(home))
.add("/about", get(about))
.add("/blog", get(blog_index))
.add("/blog/{slug}", get(blog_show))
.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))
.add("/admin/about", get(admin_about))
.add("/admin/about", post(admin_about_update))
.add("/admin/blog/articles", get(admin_articles))
.add("/admin/blog/articles/new", get(admin_article_new))
.add("/admin/blog/articles", post(admin_article_create))
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
.add("/admin/blog/articles/{id}", post(admin_article_update))
.add(
"/admin/blog/articles/{id}/delete",
post(admin_article_delete),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
pub mod admin;
pub mod auth;
pub mod blog;
pub mod cart;
pub mod catalog;
pub mod frontend;
pub mod i18n;
pub mod media;
pub mod pages;
pub mod orders;

316
src/controllers/orders.rs Normal file
View File

@@ -0,0 +1,316 @@
use crate::{
controllers::{
admin,
cart::{resolve_cart, CART_COOKIE},
catalog::format_price,
i18n::current_lang,
},
models::_entities::{order_items, orders, products},
};
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"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
email: String,
customer_name: String,
address: String,
city: String,
zip: String,
country: String,
note: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StatusForm {
status: String,
}
fn trimmed(value: &str) -> Option<String> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, total) = 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();
format::view(
&v,
"shop/checkout.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn place_order(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
) -> Result<Response> {
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()))?;
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 total: 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);
total += 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(total),
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)),
..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<serde_json::Value>)> {
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,
"total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address,
"city": order.city,
"zip": order.zip,
"country": order.country,
"note": order.note,
"created_at": order.created_at.to_rfc3339(),
});
Ok((order_json, items_json))
}
#[debug_handler]
async fn order_confirmation(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(order_number): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let list = orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = 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<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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_order_status(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<StatusForm>,
) -> Result<Response> {
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))
}

View File

@@ -1,86 +0,0 @@
use crate::{controllers::admin, models::_entities::site_pages};
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const ABOUT_SLUG: &str = "about";
#[derive(Debug, Deserialize)]
struct AboutParams {
title: String,
content: String,
}
#[derive(Debug, Serialize)]
struct PageResponse {
id: Uuid,
slug: String,
title: String,
content: String,
updated_at: chrono::DateTime<chrono::FixedOffset>,
}
impl From<site_pages::Model> for PageResponse {
fn from(page: site_pages::Model) -> Self {
Self {
id: page.id,
slug: page.slug,
title: page.title,
content: page.content,
updated_at: page.updated_at,
}
}
}
async fn find_about(ctx: &AppContext) -> Result<site_pages::Model> {
site_pages::Entity::find()
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[debug_handler]
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
format::json(PageResponse::from(find_about(&ctx).await?))
}
#[debug_handler]
async fn update_about(
auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<AboutParams>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let page = match find_about(&ctx).await {
Ok(page) => {
let mut page = page.into_active_model();
page.title = Set(params.title);
page.content = Set(params.content);
page.update(&ctx.db).await?
}
Err(Error::NotFound) => {
site_pages::ActiveModel {
id: Set(Uuid::new_v4()),
slug: Set(ABOUT_SLUG.to_string()),
title: Set(params.title),
content: Set(params.content),
..Default::default()
}
.insert(&ctx.db)
.await?
}
Err(err) => return Err(err),
};
format::json(PageResponse::from(page))
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api")
.add("/about", get(about))
.add("/admin/about", put(update_about))
}

View File

@@ -1,51 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_albums")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub title: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub cover_image_id: Option<String>,
pub artist: Option<String>,
pub release_date: Option<Date>,
pub published: bool,
pub uploader_id: i32,
pub view_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_tracks::Entity")]
AudioTracks,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UploaderId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTracks.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub name: String,
#[sea_orm(unique)]
pub slug: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
AudioTrackTags,
}
impl Related<super::audio_track_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTrackTags.def()
}
}
impl Related<super::audio_tracks::Entity> for Entity {
fn to() -> RelationDef {
super::audio_track_tags::Relation::AudioTracks.def()
}
fn via() -> Option<RelationDef> {
Some(super::audio_track_tags::Relation::AudioTags.def().rev())
}
}

View File

@@ -1,58 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_tracks")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub album_id: Option<Uuid>,
pub title: String,
pub slug: String,
pub audio_file_id: String,
pub track_number: Option<i32>,
pub duration: Option<i32>,
pub featured: bool,
pub published: bool,
pub play_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::audio_albums::Entity",
from = "Column::AlbumId",
to = "super::audio_albums::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
AudioAlbums,
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
AudioTrackTags,
}
impl Related<super::audio_albums::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioAlbums.def()
}
}
impl Related<super::audio_track_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTrackTags.def()
}
}
impl Related<super::audio_tags::Entity> for Entity {
fn to() -> RelationDef {
super::audio_track_tags::Relation::AudioTags.def()
}
fn via() -> Option<RelationDef> {
Some(super::audio_track_tags::Relation::AudioTracks.def().rev())
}
}

View File

@@ -1,42 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "blog_articles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub title: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub excerpt: Option<String>,
pub published: bool,
pub author_id: i32,
pub featured_image_id: Option<String>,
pub view_count: i32,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::AuthorId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -0,0 +1,33 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "categories")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub image_id: Option<String>,
pub position: i32,
pub published: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::products::Entity")]
Products,
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::Products.def()
}
}

View File

@@ -2,11 +2,12 @@
pub mod prelude;
pub mod audio_albums;
pub mod audio_tags;
pub mod audio_track_tags;
pub mod audio_tracks;
pub mod audit_logs;
pub mod blog_articles;
pub mod site_pages;
pub mod categories;
pub mod order_items;
pub mod orders;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod users;

View File

@@ -0,0 +1,50 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "order_items")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub product_name: String,
pub unit_price_cents: i64,
pub quantity: i32,
pub order_id: i32,
pub product_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::orders::Entity",
from = "Column::OrderId",
to = "super::orders::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Orders,
#[sea_orm(
belongs_to = "super::products::Entity",
from = "Column::ProductId",
to = "super::products::Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
Products,
}
impl Related<super::orders::Entity> for Entity {
fn to() -> RelationDef {
Relation::Orders.def()
}
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::Products.def()
}
}

View File

@@ -0,0 +1,38 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "orders")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub order_number: String,
pub email: String,
pub customer_name: Option<String>,
pub status: String,
pub total_cents: i64,
pub currency: String,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
pub country: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub note: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::order_items::Entity")]
OrderItems,
}
impl Related<super::order_items::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderItems.def()
}
}

View File

@@ -1,10 +1,11 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
pub use super::audio_albums::Entity as AudioAlbums;
pub use super::audio_tags::Entity as AudioTags;
pub use super::audio_track_tags::Entity as AudioTrackTags;
pub use super::audio_tracks::Entity as AudioTracks;
pub use super::audit_logs::Entity as AuditLogs;
pub use super::blog_articles::Entity as BlogArticles;
pub use super::site_pages::Entity as SitePages;
pub use super::categories::Entity as Categories;
pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders;
pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::products::Entity as Products;
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,35 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "product_images")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub image_id: String,
pub position: i32,
pub alt: Option<String>,
pub product_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::products::Entity",
from = "Column::ProductId",
to = "super::products::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Products,
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::Products.def()
}
}

View File

@@ -4,43 +4,42 @@ use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "audio_track_tags")]
#[sea_orm(table_name = "product_product_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub track_id: Uuid,
pub product_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: Uuid,
pub created_at: DateTimeWithTimeZone,
pub product_tag_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::audio_tags::Entity",
from = "Column::TagId",
to = "super::audio_tags::Column::Id",
belongs_to = "super::product_tags::Entity",
from = "Column::ProductTagId",
to = "super::product_tags::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AudioTags,
ProductTags,
#[sea_orm(
belongs_to = "super::audio_tracks::Entity",
from = "Column::TrackId",
to = "super::audio_tracks::Column::Id",
belongs_to = "super::products::Entity",
from = "Column::ProductId",
to = "super::products::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
AudioTracks,
Products,
}
impl Related<super::audio_tags::Entity> for Entity {
impl Related<super::product_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTags.def()
Relation::ProductTags.def()
}
}
impl Related<super::audio_tracks::Entity> for Entity {
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioTracks.def()
Relation::Products.def()
}
}

View File

@@ -0,0 +1,41 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "product_tags")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub slug: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::product_product_tags::Entity")]
ProductProductTags,
}
impl Related<super::product_product_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProductProductTags.def()
}
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
super::product_product_tags::Relation::Products.def()
}
fn via() -> Option<RelationDef> {
Some(
super::product_product_tags::Relation::ProductTags
.def()
.rev(),
)
}
}

View File

@@ -0,0 +1,77 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "products")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub price_cents: i64,
pub currency: String,
pub sku: Option<String>,
pub stock: i32,
pub view_count: i32,
pub published: bool,
pub published_at: Option<DateTimeWithTimeZone>,
pub category_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::categories::Entity",
from = "Column::CategoryId",
to = "super::categories::Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
Categories,
#[sea_orm(has_many = "super::order_items::Entity")]
OrderItems,
#[sea_orm(has_many = "super::product_images::Entity")]
ProductImages,
#[sea_orm(has_many = "super::product_product_tags::Entity")]
ProductProductTags,
}
impl Related<super::categories::Entity> for Entity {
fn to() -> RelationDef {
Relation::Categories.def()
}
}
impl Related<super::order_items::Entity> for Entity {
fn to() -> RelationDef {
Relation::OrderItems.def()
}
}
impl Related<super::product_images::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProductImages.def()
}
}
impl Related<super::product_product_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProductProductTags.def()
}
}
impl Related<super::product_tags::Entity> for Entity {
fn to() -> RelationDef {
super::product_product_tags::Relation::ProductTags.def()
}
fn via() -> Option<RelationDef> {
Some(super::product_product_tags::Relation::Products.def().rev())
}
}

View File

@@ -1,21 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "site_pages")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub slug: String,
pub title: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -29,18 +29,8 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::audio_albums::Entity")]
AudioAlbums,
#[sea_orm(has_many = "super::audit_logs::Entity")]
AuditLogs,
#[sea_orm(has_many = "super::blog_articles::Entity")]
BlogArticles,
}
impl Related<super::audio_albums::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudioAlbums.def()
}
}
impl Related<super::audit_logs::Entity> for Entity {
@@ -48,9 +38,3 @@ impl Related<super::audit_logs::Entity> for Entity {
Relation::AuditLogs.def()
}
}
impl Related<super::blog_articles::Entity> for Entity {
fn to() -> RelationDef {
Relation::BlogArticles.def()
}
}

View File

@@ -1,22 +0,0 @@
pub use super::_entities::audio_track_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AudioTrackTags = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
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 {}

View File

@@ -1,6 +1,6 @@
pub use super::_entities::audio_albums::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AudioAlbums = Entity;
pub use super::_entities::categories::{ActiveModel, Model, Entity};
pub type Categories = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {

View File

@@ -1,9 +1,10 @@
pub mod _entities;
pub mod audio_albums;
pub mod audio_tags;
pub mod audio_track_tags;
pub mod audio_tracks;
pub mod audit_logs;
pub mod blog_articles;
pub mod site_pages;
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;

View File

@@ -1,6 +1,6 @@
pub use super::_entities::audio_tracks::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AudioTracks = Entity;
pub use super::_entities::order_items::{ActiveModel, Model, Entity};
pub type OrderItems = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {

View File

@@ -1,6 +1,6 @@
pub use super::_entities::blog_articles::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type BlogArticles = Entity;
pub use super::_entities::orders::{ActiveModel, Model, Entity};
pub type Orders = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {

View File

@@ -0,0 +1,28 @@
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<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
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 {}

View File

@@ -1,6 +1,6 @@
pub use super::_entities::audio_tags::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AudioTags = Entity;
pub use super::_entities::product_product_tags::{ActiveModel, Model, Entity};
pub type ProductProductTags = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::product_tags::{ActiveModel, Model, Entity};
pub type ProductTags = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
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 {}

View File

@@ -1,6 +1,6 @@
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type SitePages = Entity;
pub use super::_entities::products::{ActiveModel, Model, Entity};
pub type Products = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
@@ -18,6 +18,11 @@ impl ActiveModelBehavior for ActiveModel {
}
}
// 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 {}