loco straucture
This commit is contained in:
22
src/models/audit_logs.rs
Normal file
22
src/models/audit_logs.rs
Normal 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
125
src/models/categories.rs
Normal 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
|
||||
}
|
||||
@@ -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
28
src/models/order_items.rs
Normal 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
127
src/models/orders.rs
Normal 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 {}
|
||||
47
src/models/product_images.rs
Normal file
47
src/models/product_images.rs
Normal 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)
|
||||
}
|
||||
22
src/models/product_product_tags.rs
Normal file
22
src/models/product_product_tags.rs
Normal 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 {}
|
||||
28
src/models/product_tags.rs
Normal file
28
src/models/product_tags.rs
Normal 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
28
src/models/products.rs
Normal 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 {}
|
||||
28
src/models/shipping_methods.rs
Normal file
28
src/models/shipping_methods.rs
Normal 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
369
src/models/users.rs
Normal 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, ¶ms.email)
|
||||
.build(),
|
||||
)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(ModelError::EntityAlreadyExists {});
|
||||
}
|
||||
|
||||
let password_hash =
|
||||
hash::hash_password(¶ms.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user