From c6624e1b3d2d389d0d470f58948deb2c6228d730 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 18 Jun 2026 21:11:48 +0200 Subject: [PATCH] profile of a new registered users --- assets/i18n/en/main.ftl | 6 + assets/i18n/sk/main.ftl | 6 + assets/views/account/profile.html | 114 ++++++++++++++++++ assets/views/base.html | 14 +++ assets/views/shop/checkout.html | 21 ++-- migration/src/lib.rs | 2 + .../src/m20260618_000002_customer_profiles.rs | 44 +++++++ src/app.rs | 3 +- src/controllers/account.rs | 112 +++++++++++++++++ src/controllers/cart.rs | 5 +- src/controllers/checkout.rs | 56 ++++++++- src/controllers/home.rs | 4 +- src/controllers/mod.rs | 1 + src/controllers/shop.rs | 12 +- src/models/_entities/customer_profiles.rs | 40 ++++++ src/models/_entities/mod.rs | 1 + src/models/_entities/prelude.rs | 1 + src/models/customer_profiles.rs | 64 ++++++++++ src/models/mod.rs | 1 + src/shared/guard.rs | 11 ++ 20 files changed, 501 insertions(+), 17 deletions(-) create mode 100644 assets/views/account/profile.html create mode 100644 migration/src/m20260618_000002_customer_profiles.rs create mode 100644 src/controllers/account.rs create mode 100644 src/models/_entities/customer_profiles.rs create mode 100644 src/models/customer_profiles.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 31f2898..591e9c0 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -70,6 +70,7 @@ auth-or = or auth-google = Continue with Google nav-login = Sign in nav-register = Register +nav-profile = My profile register-title = Create account register-name = Name register-submit = Create account @@ -256,8 +257,13 @@ country-de = Germany country-pl = Poland country-hu = Hungary checkout-note = Order note +checkout-save-profile = Save this address to my profile checkout-place-order = Place order checkout-summary = Order summary +profile-title = My profile +profile-intro = We'll use these details to prefill checkout. +profile-saved = Profile saved. +profile-save = Save profile order-confirmed-title = Thank you for your order! order-confirmed-sub = We have received your order. order-number = Order number diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 3962cfd..88c5c83 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -70,6 +70,7 @@ auth-or = alebo auth-google = Pokračovať cez Google nav-login = Prihlásiť sa nav-register = Registrácia +nav-profile = Môj profil register-title = Vytvoriť účet register-name = Meno register-submit = Zaregistrovať sa @@ -256,8 +257,13 @@ country-de = Nemecko country-pl = Poľsko country-hu = Maďarsko checkout-note = Poznámka k objednávke +checkout-save-profile = Uložiť túto adresu do môjho profilu checkout-place-order = Odoslať objednávku checkout-summary = Súhrn objednávky +profile-title = Môj profil +profile-intro = Tieto údaje použijeme na predvyplnenie pokladne. +profile-saved = Profil bol uložený. +profile-save = Uložiť profil order-confirmed-title = Ďakujeme za objednávku! order-confirmed-sub = Vašu objednávku sme prijali. order-number = Číslo objednávky diff --git a/assets/views/account/profile.html b/assets/views/account/profile.html new file mode 100644 index 0000000..0e10064 --- /dev/null +++ b/assets/views/account/profile.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as ui %} + +{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +
+

{{ t(key="profile-title", lang=lang | default(value='sk')) }}

+

{{ t(key="profile-intro", lang=lang | default(value='sk')) }}

+ + {% if saved %} +
+ {{ t(key="profile-saved", lang=lang | default(value='sk')) }} +
+ {% endif %} + +
+ +
+ {{ t(key="checkout-contact", lang=lang | default(value='sk')) }} +
+ +

{{ name }}

+
+
+ +

{{ email }}

+
+
+ +
+ +
+ + +
    + +
+
+ {{ ui::input(name="phone", id="phone", type="tel", value=phone | default(value=''), autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }} +
+
+
+ + +
+ {{ t(key="checkout-shipping", lang=lang | default(value='sk')) }} +
+ + {{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }} +
+
+
+ + {{ ui::input(name="city", id="city", value=city | default(value=''), autocomplete="address-level2") }} +
+
+ + {{ ui::input(name="zip", id="zip", value=zip | default(value=''), autocomplete="postal-code") }} +
+
+ +
+ + +
    + +
+
+
+
+
+ + {{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }} +
+
+{% endblock content %} diff --git a/assets/views/base.html b/assets/views/base.html index d4e2454..43baef3 100644 --- a/assets/views/base.html +++ b/assets/views/base.html @@ -85,6 +85,13 @@ + {% elif logged_in_customer %} +
  • {{ ui::nav_link(label=t(key="nav-profile", lang=lang | default(value='sk')), href="/account/profile", data_nav="/account") }}
  • +
  • +
    + +
    +
  • {% else %}
  • {{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}
  • {{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}
  • @@ -131,6 +138,13 @@ + {% elif logged_in_customer %} +
  • {{ t(key="nav-profile", lang=lang | default(value='sk')) }}
  • +
  • +
    + +
    +
  • {% else %}
  • {{ t(key="nav-login", lang=lang | default(value='sk')) }}
  • {{ t(key="nav-register", lang=lang | default(value='sk')) }}
  • diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout.html index c28c8f4..8ccc3f2 100644 --- a/assets/views/shop/checkout.html +++ b/assets/views/shop/checkout.html @@ -36,18 +36,18 @@ {{ t(key="checkout-contact", lang=lang | default(value='sk')) }}
    - {{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }} + {{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }}
    - {{ ui::input(name="customer_name", id="customer_name", required=true, autocomplete="name") }} + {{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }}
    - {{ ui::input(name="phone", id="phone", type="tel", required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }} + {{ ui::input(name="phone", id="phone", type="tel", value=prefill_phone | default(value=''), required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
    @@ -81,21 +81,21 @@ {{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}
    - {{ ui::input(name="address", id="address", required=true, autocomplete="street-address") }} + {{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
    - {{ ui::input(name="city", id="city", required=true, autocomplete="address-level2") }} + {{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
    - {{ ui::input(name="zip", id="zip", required=true, autocomplete="postal-code") }} + {{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
    {{ t(key="checkout-note", lang=lang | default(value='sk')) }} {{ ui::textarea(name="note", id="note", rows="3") }}
    + + {% if logged_in_customer %} + + {{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk')), checked=true) }} + {% endif %}
    diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 8db63e6..2594493 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -31,6 +31,7 @@ mod m20260617_000001_add_carrier_to_shipping_methods; mod m20260617_000002_add_shipment_to_orders; mod m20260617_000003_add_phone_to_orders; mod m20260618_000001_o_auth2_sessions; +mod m20260618_000002_customer_profiles; pub struct Migrator; #[async_trait::async_trait] @@ -66,6 +67,7 @@ impl MigratorTrait for Migrator { Box::new(m20260617_000002_add_shipment_to_orders::Migration), Box::new(m20260617_000003_add_phone_to_orders::Migration), Box::new(m20260618_000001_o_auth2_sessions::Migration), + Box::new(m20260618_000002_customer_profiles::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260618_000002_customer_profiles.rs b/migration/src/m20260618_000002_customer_profiles.rs new file mode 100644 index 0000000..2e1b372 --- /dev/null +++ b/migration/src/m20260618_000002_customer_profiles.rs @@ -0,0 +1,44 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + // One shipping/contact profile per customer, used to prefill the + // checkout form. `name`/`email` already live on `users`; this table + // holds only the address + phone fields. `user` adds a user_id FK; the + // unique index below makes the relationship 1:1. + create_table( + m, + "customer_profiles", + &[ + ("id", ColType::PkAuto), + ("phone_prefix", ColType::StringNull), + ("phone", ColType::StringNull), + ("address", ColType::StringNull), + ("city", ColType::StringNull), + ("zip", ColType::StringNull), + ("country", ColType::StringNull), + ], + &[("user", "")], + ) + .await?; + + m.create_index( + Index::create() + .name("idx_customer_profiles_user_id_unique") + .table(Alias::new("customer_profiles")) + .col(Alias::new("user_id")) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "customer_profiles").await + } +} diff --git a/src/app.rs b/src/app.rs index a4e00e9..50f2f7c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,7 +17,7 @@ use std::{path::Path, sync::Arc}; #[allow(unused_imports)] use crate::{ controllers::{ - admin_categories, admin_dashboard, admin_form, admin_orders, + account, admin_categories, admin_dashboard, admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2, shop, }, @@ -91,6 +91,7 @@ impl Hooks for App { // cross-cutting .add_route(auth::routes()) .add_route(auth_pages::routes()) + .add_route(account::routes()) .add_route(oauth2::routes()) .add_route(i18n::routes()) .add_route(media::routes()) diff --git a/src/controllers/account.rs b/src/controllers/account.rs new file mode 100644 index 0000000..9f6ea6e --- /dev/null +++ b/src/controllers/account.rs @@ -0,0 +1,112 @@ +//! Customer account area. Currently just the shipping/contact profile, whose +//! fields prefill the checkout form. Gated to authenticated non-admin users: +//! anonymous visitors are bounced to `/login`. Admins have their own area and +//! are sent to the dashboard. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + controllers::i18n::current_lang, + models::customer_profiles::{self, ProfileFields}, + shared::guard, +}; + +#[derive(Debug, Deserialize)] +struct ProfileForm { + phone_prefix: Option, + phone: Option, + address: Option, + city: Option, + zip: Option, + country: Option, +} + +fn trimmed(value: Option<&str>) -> Option { + value.map(str::trim).filter(|v| !v.is_empty()).map(String::from) +} + +impl From for ProfileFields { + fn from(form: ProfileForm) -> Self { + Self { + phone_prefix: trimmed(form.phone_prefix.as_deref()), + phone: trimmed(form.phone.as_deref()), + address: trimmed(form.address.as_deref()), + city: trimmed(form.city.as_deref()), + zip: trimmed(form.zip.as_deref()), + country: trimmed(form.country.as_deref()), + } + } +} + +/// Render the profile form for `profile` (which may be `None` for a customer +/// who hasn't saved anything yet). `saved` shows the success banner after a +/// POST. +fn profile_view( + v: &TeraView, + jar: &CookieJar, + name: &str, + email: &str, + profile: Option<&customer_profiles::Model>, + saved: bool, +) -> Result { + format::view( + v, + "account/profile.html", + json!({ + "logged_in_admin": false, + "logged_in_customer": true, + "saved": saved, + "name": name, + "email": email, + "phone_prefix": profile.and_then(|p| p.phone_prefix.clone()), + "phone": profile.and_then(|p| p.phone.clone()), + "address": profile.and_then(|p| p.address.clone()), + "city": profile.and_then(|p| p.city.clone()), + "zip": profile.and_then(|p| p.zip.clone()), + "country": profile.and_then(|p| p.country.clone()), + "lang": current_lang(jar), + }), + ) +} + +#[debug_handler] +async fn profile_page( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let Some(user) = guard::current_user(&ctx, &jar).await else { + return format::redirect("/login"); + }; + if guard::is_admin(&ctx, &user) { + return format::redirect("/admin/dashboard"); + } + let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?; + profile_view(&v, &jar, &user.name, &user.email, profile.as_ref(), false) +} + +#[debug_handler] +async fn save_profile( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, + Form(form): Form, +) -> Result { + let Some(user) = guard::current_user(&ctx, &jar).await else { + return format::redirect("/login"); + }; + if guard::is_admin(&ctx, &user) { + return format::redirect("/admin/dashboard"); + } + let profile = customer_profiles::Model::upsert(&ctx.db, user.id, form.into()).await?; + profile_view(&v, &jar, &user.name, &user.email, Some(&profile), true) +} + +pub fn routes() -> Routes { + Routes::new() + .add("/account/profile", get(profile_page)) + .add("/account/profile", post(save_profile)) +} diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs index a3c7ae3..e181a95 100644 --- a/src/controllers/cart.rs +++ b/src/controllers/cart.rs @@ -1,4 +1,4 @@ -use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products}; +use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price}, models::products}; use axum::{ http::{HeaderMap, StatusCode}, response::Redirect, @@ -234,6 +234,7 @@ async fn show( // Drop any now-invalid lines from the cookie so the badge stays accurate. let rebuilt = serialize_cart(&valid); + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let response = format::view( &v, "shop/cart.html", @@ -241,6 +242,8 @@ async fn show( "items": lines, "total": format_price(total), "currency": currency, + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), )?; diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs index 9375273..37a627e 100644 --- a/src/controllers/checkout.rs +++ b/src/controllers/checkout.rs @@ -10,9 +10,9 @@ use time::Duration as TimeDuration; use crate::{ controllers::cart::{resolve_cart, CART_COOKIE}, - models::{order_items, orders, shipping_methods}, + models::{customer_profiles::{self, ProfileFields}, order_items, orders, shipping_methods}, controllers::i18n::current_lang, - shared::{money::format_price, settings}, + shared::{guard, money::format_price, settings}, views::checkout as view, }; @@ -33,6 +33,8 @@ struct CheckoutForm { carrier_code: String, pickup_point_id: Option, pickup_point_name: Option, + // Present (as "on") only when a logged-in customer ticks "save my address". + save_profile: Option, } fn trimmed(value: &str) -> Option { @@ -86,6 +88,19 @@ async fn checkout_page( }) .collect(); + // Prefill the form for a logged-in customer: contact name/email come from + // the user account, the address/phone from their saved profile (if any). + let user = guard::current_user(&ctx, &jar).await; + let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u)); + let is_customer = user.is_some() && !is_admin; + let profile = match (&user, is_customer) { + (Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?, + _ => None, + }; + let p = |get: fn(&customer_profiles::Model) -> Option| { + profile.as_ref().and_then(get) + }; + format::view( &v, "shop/checkout.html", @@ -96,6 +111,16 @@ async fn checkout_page( "currency": currency, "shipping_methods": methods, "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), + "logged_in_admin": is_admin, + "logged_in_customer": is_customer, + "prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()), + "prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), + "prefill_phone_prefix": p(|x| x.phone_prefix.clone()), + "prefill_phone": p(|x| x.phone.clone()), + "prefill_address": p(|x| x.address.clone()), + "prefill_city": p(|x| x.city.clone()), + "prefill_zip": p(|x| x.zip.clone()), + "prefill_country": p(|x| x.country.clone()), "lang": current_lang(&jar), }), ) @@ -119,7 +144,7 @@ async fn place_order( trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?; let phone = match trimmed(&form.phone_prefix) { Some(prefix) => format!("{prefix} {number}"), - None => number, + None => number.clone(), }; // Contact and shipping-address fields are mandatory (also enforced in the @@ -157,6 +182,28 @@ async fn place_order( (None, None) }; + // If a logged-in customer opted in, persist this address to their profile + // so the next checkout is prefilled. Phone is stored split (prefix + number) + // to match the profile/checkout fields. Best-effort: a failure here is logged + // but must not block the order. + if form.save_profile.is_some() { + if let Some(user) = guard::current_user(&ctx, &jar).await { + if !guard::is_admin(&ctx, &user) { + let fields = ProfileFields { + phone_prefix: trimmed(&form.phone_prefix), + phone: Some(number.clone()), + address: Some(address.clone()), + city: Some(city.clone()), + zip: Some(zip.clone()), + country: Some(country.clone()), + }; + if let Err(err) = customer_profiles::Model::upsert(&ctx.db, user.id, fields).await { + tracing::error!(error = %err, user_id = user.id, "failed to save checkout profile"); + } + } + } + } + let order = orders::place( &ctx, &valid, @@ -198,6 +245,7 @@ async fn order_confirmation( .filter(order_items::Column::OrderId.eq(order.id)) .all(&ctx.db) .await?; + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; format::view( &v, @@ -209,6 +257,8 @@ async fn order_confirmation( settings::get(&ctx, "bank_account_name").unwrap_or(""), ), "items": view::items(&items), + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), ) diff --git a/src/controllers/home.rs b/src/controllers/home.rs index 9eb71e9..a526b68 100644 --- a/src/controllers/home.rs +++ b/src/controllers/home.rs @@ -13,13 +13,15 @@ async fn index( State(ctx): State, ) -> Result { let products = shop::featured_products(&ctx, 8).await?; + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; format::view( &v, "home/index.html", json!({ "products": products, - "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), ) diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 85d8ed5..c02c866 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,3 +1,4 @@ +pub mod account; pub mod auth; pub mod auth_pages; pub mod oauth2; diff --git a/src/controllers/shop.rs b/src/controllers/shop.rs index c32453b..4bdc713 100644 --- a/src/controllers/shop.rs +++ b/src/controllers/shop.rs @@ -69,12 +69,14 @@ async fn index( .all(&ctx.db) .await?; + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; format::view( &v, "shop/index.html", json!({ "products": product_rows(&ctx, list).await?, - "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), ) @@ -108,6 +110,7 @@ async fn show( None => None, }; + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; format::view( &v, "shop/show.html", @@ -115,7 +118,8 @@ async fn show( "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, + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), ) @@ -151,6 +155,7 @@ async fn category( .all(&ctx.db) .await?; + let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; format::view( &v, "shop/category.html", @@ -159,7 +164,8 @@ async fn category( "breadcrumbs": breadcrumbs, "children": children, "products": product_rows(&ctx, list).await?, - "logged_in_admin": guard::logged_in(&ctx, &jar).await, + "logged_in_admin": logged_in_admin, + "logged_in_customer": logged_in_customer, "lang": current_lang(&jar), }), ) diff --git a/src/models/_entities/customer_profiles.rs b/src/models/_entities/customer_profiles.rs new file mode 100644 index 0000000..9ea4f85 --- /dev/null +++ b/src/models/_entities/customer_profiles.rs @@ -0,0 +1,40 @@ +//! `SeaORM` Entity for customer shipping/contact profiles. Hand-written to match +//! the `customer_profiles` migration (1:1 with `users` via a unique `user_id`). + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "customer_profiles")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub user_id: i32, + pub phone_prefix: Option, + pub phone: Option, + pub address: Option, + pub city: Option, + pub zip: Option, + pub country: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 660d322..410f35b 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -4,6 +4,7 @@ pub mod prelude; pub mod audit_logs; pub mod categories; +pub mod customer_profiles; pub mod o_auth2_sessions; pub mod order_items; pub mod orders; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 7f2b205..dde8846 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -2,6 +2,7 @@ pub use super::audit_logs::Entity as AuditLogs; pub use super::categories::Entity as Categories; +pub use super::customer_profiles::Entity as CustomerProfiles; pub use super::o_auth2_sessions::Entity as OAuth2Sessions; pub use super::order_items::Entity as OrderItems; pub use super::orders::Entity as Orders; diff --git a/src/models/customer_profiles.rs b/src/models/customer_profiles.rs new file mode 100644 index 0000000..20a8419 --- /dev/null +++ b/src/models/customer_profiles.rs @@ -0,0 +1,64 @@ +//! Per-customer shipping/contact profile: the address + phone fields used to +//! prefill checkout. One row per user (unique `user_id`); `name`/`email` are +//! read from `users`, never duplicated here. + +pub use crate::models::_entities::customer_profiles::{ActiveModel, Column, Entity, Model}; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue, IntoActiveModel, QueryFilter, TryIntoModel}; + +pub type CustomerProfiles = Entity; + +/// The editable profile fields, shared by the profile page and the checkout +/// "save my address" path. +#[derive(Debug, Default, Clone)] +pub struct ProfileFields { + pub phone_prefix: Option, + pub phone: Option, + pub address: Option, + pub city: Option, + pub zip: Option, + pub country: Option, +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} + +impl Model { + /// The profile for `user_id`, if one exists. + pub async fn find_for_user(db: &DatabaseConnection, user_id: i32) -> Result, DbErr> { + Entity::find() + .filter(Column::UserId.eq(user_id)) + .one(db) + .await + } + + /// Insert or update the profile for `user_id` with `fields`, returning the + /// persisted row. The unique `user_id` index keeps this 1:1. + pub async fn upsert( + db: &DatabaseConnection, + user_id: i32, + fields: ProfileFields, + ) -> Result { + let mut active = match Self::find_for_user(db, user_id).await? { + Some(existing) => existing.into_active_model(), + None => ActiveModel { + user_id: ActiveValue::set(user_id), + ..Default::default() + }, + }; + active.phone_prefix = ActiveValue::set(fields.phone_prefix); + active.phone = ActiveValue::set(fields.phone); + active.address = ActiveValue::set(fields.address); + active.city = ActiveValue::set(fields.city); + active.zip = ActiveValue::set(fields.zip); + active.country = ActiveValue::set(fields.country); + active.save(db).await?.try_into_model() + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 37f2ff8..4f38ed9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -8,6 +8,7 @@ pub mod _entities; pub mod audit_logs; pub mod categories; +pub mod customer_profiles; pub mod o_auth2_sessions; pub mod order_items; pub mod orders; diff --git a/src/shared/guard.rs b/src/shared/guard.rs index 56ddc03..6a3d8a1 100644 --- a/src/shared/guard.rs +++ b/src/shared/guard.rs @@ -45,3 +45,14 @@ pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool { None => false, } } + +/// Nav chrome flags for storefront pages, in one DB lookup: returns +/// `(logged_in_admin, logged_in_customer)`. A customer is any authenticated +/// non-admin user. Both are `false` for anonymous visitors. +pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> (bool, bool) { + match current_user(ctx, jar).await { + Some(user) if is_admin(ctx, &user) => (true, false), + Some(_) => (false, true), + None => (false, false), + } +}