loco straucture

This commit is contained in:
Priec
2026-06-16 23:40:53 +02:00
parent 9ce07e8c23
commit b88c990873
43 changed files with 378 additions and 102 deletions

22
src/models/audit_logs.rs Normal file
View File

@@ -0,0 +1,22 @@
pub use crate::models::_entities::audit_logs::{ActiveModel, Entity, Model};
use sea_orm::entity::prelude::*;
pub type AuditLogs = 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 {}

125
src/models/categories.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::collections::{HashMap, HashSet};
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::categories::{ActiveModel, Column, Entity, Model};
pub type Categories = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<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 write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/// Every category, the source for tree building and validation.
pub async fn all(ctx: &AppContext) -> Result<Vec<Model>> {
Ok(Entity::find().all(&ctx.db).await?)
}
/// Only published categories, for the storefront.
pub async fn published(ctx: &AppContext) -> Result<Vec<Model>> {
Ok(Entity::find()
.filter(Column::Published.eq(true))
.all(&ctx.db)
.await?)
}
// ---------------------------------------------------------------------------
// Hierarchy (adjacency list via `parent_id`)
// ---------------------------------------------------------------------------
/// Flatten the category forest into a depth-first ordered list of
/// `(category, depth)`, sorting siblings by position then name. `depth` is 0
/// for top-level categories and increases by one per level — templates use it
/// to indent.
pub fn tree(categories: &[Model]) -> Vec<(Model, usize)> {
let mut children: HashMap<Option<i32>, Vec<&Model>> = HashMap::new();
for category in categories {
children.entry(category.parent_id).or_default().push(category);
}
for siblings in children.values_mut() {
siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
}
fn walk(
parent: Option<i32>,
depth: usize,
children: &HashMap<Option<i32>, Vec<&Model>>,
out: &mut Vec<(Model, usize)>,
) {
if let Some(siblings) = children.get(&parent) {
for category in siblings {
out.push(((*category).clone(), depth));
walk(Some(category.id), depth + 1, children, out);
}
}
}
let mut out = Vec::new();
walk(None, 0, &children, &mut out);
out
}
/// Ids of every descendant of `root` (children, grandchildren, …), not
/// including `root` itself.
pub fn descendant_ids(categories: &[Model], root: i32) -> HashSet<i32> {
let mut set = HashSet::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
for child in categories.iter().filter(|c| c.parent_id == Some(id)) {
if set.insert(child.id) {
stack.push(child.id);
}
}
}
set
}
/// Ancestor chain (root first … immediate parent last) for breadcrumbs.
pub fn ancestors(categories: &[Model], start_parent: Option<i32>) -> Vec<Model> {
let mut chain = Vec::new();
let mut current = start_parent;
while let Some(id) = current {
match categories.iter().find(|c| c.id == id) {
Some(category) => {
current = category.parent_id;
chain.push(category.clone());
}
None => break,
}
}
chain.reverse();
chain
}
/// Published direct children of `parent_id`, sorted for sub-navigation.
pub fn children_of(categories: &[Model], parent_id: i32) -> Vec<Model> {
let mut children: Vec<Model> = categories
.iter()
.filter(|c| c.parent_id == Some(parent_id))
.cloned()
.collect();
children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
children
}

View File

@@ -1,7 +1,18 @@
//! Shared data layer: the sea-orm entities generated by `loco generate`.
//! Shared data layer: SeaORM entities and their hand-written model extensions.
//!
//! These structs cross-reference each other (relations) and are regenerated as
//! a unit, so they live here centrally. The hand-written model methods,
//! services and view-shaping that use them live in the feature slices
//! (`shop::models`, `checkout::models`, `account::models`, …).
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
//! finder methods, business logic, and query helpers.
pub mod _entities;
pub mod audit_logs;
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 shipping_methods;
pub mod users;

28
src/models/order_items.rs Normal file
View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::order_items::{ActiveModel, Column, Entity, Model};
pub type OrderItems = 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 {}

127
src/models/orders.rs Normal file
View File

@@ -0,0 +1,127 @@
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*;
use sea_orm::{Set, TransactionTrait};
use uuid::Uuid;
use crate::models::_entities::{order_items, products, shipping_methods};
pub use crate::models::_entities::orders::{ActiveModel, Column, Entity, Model};
pub type Orders = Entity;
/// The customer-supplied and carrier details needed to place an order. Prices
/// and product names are never taken from here — they are snapshotted from the
/// database inside [`place`] so the customer cannot influence what they pay.
pub struct Checkout {
pub email: String,
pub customer_name: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
pub country: Option<String>,
pub note: Option<String>,
pub payment_method: String,
pub method: shipping_methods::Model,
pub pickup_point_id: Option<String>,
pub pickup_point_name: Option<String>,
}
fn generate_order_number() -> String {
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
format!("ORD-{suffix}")
}
/// Atomically place an order for the given `(product_id, quantity)` lines:
/// snapshot each product's price/name, decrement stock (re-checking inside the
/// transaction so an item can't oversell between cart and pay), then write the
/// order and its line items. Returns the persisted order.
pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> Result<Model> {
let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new();
for (product_id, qty) in items {
let product = products::Entity::find_by_id(*product_id)
.filter(products::Column::Published.eq(true))
.one(&txn)
.await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
if product.stock < *qty {
return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
}
currency = product.currency.clone();
subtotal += product.price_cents * i64::from(*qty);
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
active.update(&txn).await?;
snapshots.push((product.id, product.name, product.price_cents, *qty));
}
let order = ActiveModel {
order_number: Set(generate_order_number()),
email: Set(details.email),
customer_name: Set(details.customer_name),
status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents),
currency: Set(currency),
address: Set(details.address),
city: Set(details.city),
zip: Set(details.zip),
country: Set(details.country),
note: Set(details.note),
payment_method: Set(Some(details.payment_method)),
carrier_code: Set(Some(details.method.code)),
carrier_name: Set(Some(details.method.name)),
shipping_cents: Set(details.method.price_cents),
pickup_point_id: Set(details.pickup_point_id),
pickup_point_name: Set(details.pickup_point_name),
..Default::default()
}
.insert(&txn)
.await?;
for (product_id, name, unit_price_cents, qty) in snapshots {
order_items::ActiveModel {
order_id: Set(order.id),
product_id: Set(Some(product_id)),
product_name: Set(name),
unit_price_cents: Set(unit_price_cents),
quantity: Set(qty),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(order)
}
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<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

@@ -0,0 +1,47 @@
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*;
use sea_orm::QueryOrder;
pub use crate::models::_entities::product_images::{ActiveModel, Column, Entity, Model};
pub type ProductImages = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<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 write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
/// Filename of a product's primary (lowest-position) image, if any.
pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
Ok(Entity::find()
.filter(Column::ProductId.eq(product_id))
.order_by_asc(Column::Position)
.one(&ctx.db)
.await?
.map(|image| image.image_id))
}
/// Number of images already attached to a product, used to position new uploads.
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> {
use sea_orm::PaginatorTrait;
Ok(Entity::find()
.filter(Column::ProductId.eq(product_id))
.count(&ctx.db)
.await? as i32)
}

View File

@@ -0,0 +1,22 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::product_product_tags::{ActiveModel, Model, Entity};
pub type ProductProductTags = 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

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_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 {}

28
src/models/products.rs Normal file
View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
pub type Products = 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

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::shipping_methods::{ActiveModel, Column, Entity, Model};
pub type ShippingMethods = 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 {}

369
src/models/users.rs Normal file
View File

@@ -0,0 +1,369 @@
use async_trait::async_trait;
use chrono::{offset::Local, Duration};
use loco_rs::{auth::jwt, hash, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Map;
use uuid::Uuid;
pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model};
pub const MAGIC_LINK_LENGTH: i8 = 32;
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
#[derive(Debug, Deserialize, Serialize)]
pub struct LoginParams {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterParams {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Validate, Deserialize)]
pub struct Validator {
#[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
pub name: String,
#[validate(email(message = "invalid email"))]
pub email: String,
}
impl Validatable for ActiveModel {
fn validator(&self) -> Box<dyn Validate> {
Box::new(Validator {
name: self.name.as_ref().to_owned(),
email: self.email.as_ref().to_owned(),
})
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for crate::models::_entities::users::ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
self.validate()?;
if insert {
let mut this = self;
this.pid = ActiveValue::Set(Uuid::new_v4());
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
Ok(this)
} else {
Ok(self)
}
}
}
#[async_trait]
impl Authenticable for Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ApiKey, api_key)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
Self::find_by_pid(db, claims_key).await
}
}
impl Model {
/// finds a user by the provided email
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, email)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided verification token
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_verification_token(
db: &DatabaseConnection,
token: &str,
) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::EmailVerificationToken, token)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the magic token and verify and token expiration
///
/// # Errors
///
/// When could not find user by the given token or DB query error ot token expired
pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
query::condition()
.eq(users::Column::MagicLinkToken, token)
.build(),
)
.one(db)
.await?;
let user = user.ok_or_else(|| ModelError::EntityNotFound)?;
if let Some(expired_at) = user.magic_link_expiration {
if expired_at >= Local::now() {
Ok(user)
} else {
tracing::debug!(
user_pid = user.pid.to_string(),
token_expiration = expired_at.to_string(),
"magic token expired for the user."
);
Err(ModelError::msg("magic token expired"))
}
} else {
tracing::error!(
user_pid = user.pid.to_string(),
"magic link expiration time not exists"
);
Err(ModelError::msg("expiration token not exists"))
}
}
/// finds a user by the provided reset token
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ResetToken, token)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided pid
///
/// # Errors
///
/// When could not find user or DB query error
pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult<Self> {
let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?;
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Pid, parse_uuid)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// finds a user by the provided api key
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::ApiKey, api_key)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
/// Verifies whether the provided plain password matches the hashed password
///
/// # Errors
///
/// when could not verify password
#[must_use]
pub fn verify_password(&self, password: &str) -> bool {
hash::verify_password(password, &self.password)
}
/// Asynchronously creates a user with a password and saves it to the
/// database.
///
/// # Errors
///
/// When could not save the user into the DB
pub async fn create_with_password(
db: &DatabaseConnection,
params: &RegisterParams,
) -> ModelResult<Self> {
let txn = db.begin().await?;
if users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, &params.email)
.build(),
)
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityAlreadyExists {});
}
let password_hash =
hash::hash_password(&params.password).map_err(|e| ModelError::Any(e.into()))?;
let user = users::ActiveModel {
email: ActiveValue::set(params.email.to_string()),
password: ActiveValue::set(password_hash),
name: ActiveValue::set(params.name.to_string()),
..Default::default()
}
.insert(&txn)
.await?;
txn.commit().await?;
Ok(user)
}
/// Creates a JWT
///
/// # Errors
///
/// when could not convert user claims to jwt token
pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult<String> {
jwt::JWT::new(secret)
.generate_token(expiration, self.pid.to_string(), Map::new())
.map_err(ModelError::from)
}
}
impl ActiveModel {
/// Sets the email verification information for the user and
/// updates it in the database.
///
/// This method is used to record the timestamp when the email verification
/// was sent and generate a unique verification token for the user.
///
/// # Errors
///
/// when has DB query error
pub async fn set_email_verification_sent(
mut self,
db: &DatabaseConnection,
) -> ModelResult<Model> {
self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into()));
self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
self.update(db).await.map_err(ModelError::from)
}
/// Sets the information for a reset password request,
/// generates a unique reset password token, and updates it in the
/// database.
///
/// This method records the timestamp when the reset password token is sent
/// and generates a unique token for the user.
///
/// # Arguments
///
/// # Errors
///
/// when has DB query error
pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.reset_sent_at = ActiveValue::set(Some(Local::now().into()));
self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string()));
self.update(db).await.map_err(ModelError::from)
}
/// Records the verification time when a user verifies their
/// email and updates it in the database.
///
/// This method sets the timestamp when the user successfully verifies their
/// email.
///
/// # Errors
///
/// when has DB query error
pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.email_verified_at = ActiveValue::set(Some(Local::now().into()));
self.update(db).await.map_err(ModelError::from)
}
/// Resets the current user password with a new password and
/// updates it in the database.
///
/// This method hashes the provided password and sets it as the new password
/// for the user.
///
/// # Errors
///
/// when has DB query error or could not hashed the given password
pub async fn reset_password(
mut self,
db: &DatabaseConnection,
password: &str,
) -> ModelResult<Model> {
self.password =
ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?);
self.reset_token = ActiveValue::Set(None);
self.reset_sent_at = ActiveValue::Set(None);
self.update(db).await.map_err(ModelError::from)
}
/// Creates a magic link token for passwordless authentication.
///
/// Generates a random token with a specified length and sets an expiration time
/// for the magic link. This method is used to initiate the magic link authentication flow.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize);
let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into());
self.magic_link_token = ActiveValue::set(Some(random_str));
self.magic_link_expiration = ActiveValue::set(Some(expired.into()));
self.update(db).await.map_err(ModelError::from)
}
/// Verifies and invalidates the magic link after successful authentication.
///
/// Clears the magic link token and expiration time after the user has
/// successfully authenticated using the magic link.
///
/// # Errors
/// - Returns an error if database update fails
pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult<Model> {
self.magic_link_token = ActiveValue::set(None);
self.magic_link_expiration = ActiveValue::set(None);
self.update(db).await.map_err(ModelError::from)
}
}