532 lines
17 KiB
Rust
532 lines
17 KiB
Rust
use std::sync::Arc;
|
|
|
|
use crate::domain::{DomainError, DomainResult, GroceryList, Item, Meal, SessionUser, User};
|
|
use crate::ports::{
|
|
CategoryRepository, InvitationRepository, ItemRepository, ListRepository,
|
|
MealIngredientRepository, MealRepository, NewItem, PasswordHasher, RealtimeNotifier,
|
|
SessionRepository, TokenGenerator, UserRepository,
|
|
};
|
|
use crate::sqlite::SqliteDatabase;
|
|
|
|
pub struct AuthService {
|
|
db: SqliteDatabase,
|
|
users: Arc<dyn UserRepository>,
|
|
sessions: Arc<dyn SessionRepository>,
|
|
invitations: Arc<dyn InvitationRepository>,
|
|
hasher: Arc<dyn PasswordHasher>,
|
|
registration_mode: RegistrationMode,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum RegistrationMode {
|
|
Open,
|
|
InviteOnly,
|
|
}
|
|
|
|
impl AuthService {
|
|
pub fn new(
|
|
db: SqliteDatabase,
|
|
users: Arc<dyn UserRepository>,
|
|
sessions: Arc<dyn SessionRepository>,
|
|
invitations: Arc<dyn InvitationRepository>,
|
|
hasher: Arc<dyn PasswordHasher>,
|
|
registration_mode: RegistrationMode,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
users,
|
|
sessions,
|
|
invitations,
|
|
hasher,
|
|
registration_mode,
|
|
}
|
|
}
|
|
|
|
pub async fn can_register(&self, invite: Option<&str>) -> DomainResult<bool> {
|
|
if self.registration_mode == RegistrationMode::Open {
|
|
return Ok(true);
|
|
}
|
|
let users = Arc::clone(&self.users);
|
|
let invitations = Arc::clone(&self.invitations);
|
|
let invite = invite.map(str::to_owned);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
if !users.has_users(txn).await? {
|
|
return Ok(true);
|
|
}
|
|
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
|
|
return Ok(false);
|
|
};
|
|
invitations.invitation(txn, invite).await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn register(
|
|
&self,
|
|
display_name: String,
|
|
email: String,
|
|
password: String,
|
|
invite: Option<&str>,
|
|
) -> DomainResult<(User, String)> {
|
|
if !self.can_register(invite).await? {
|
|
return Err(DomainError::Conflict);
|
|
}
|
|
let password_hash = self.hasher.hash(&password)?;
|
|
let users = Arc::clone(&self.users);
|
|
let sessions = Arc::clone(&self.sessions);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
let user = users
|
|
.create_user(txn, email, display_name, password_hash)
|
|
.await?;
|
|
let (session_token, _) = sessions.create_session(txn, user.id).await?;
|
|
Ok((user, session_token))
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn login(
|
|
&self,
|
|
email: String,
|
|
password: String,
|
|
) -> DomainResult<Option<(User, String)>> {
|
|
let users = Arc::clone(&self.users);
|
|
let sessions = Arc::clone(&self.sessions);
|
|
let hasher = Arc::clone(&self.hasher);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
let Some((user, password_hash)) = users.find_user_by_email(txn, email).await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let valid = hasher.verify(&password, &password_hash)?;
|
|
if !valid {
|
|
return Ok(None);
|
|
}
|
|
let (session_token, _) = sessions.create_session(txn, user.id).await?;
|
|
Ok(Some((user, session_token)))
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn session_user(&self, session_token: String) -> DomainResult<Option<SessionUser>> {
|
|
let sessions = Arc::clone(&self.sessions);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move { sessions.session_user(txn, session_token).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn find_user_by_email(&self, email: String) -> DomainResult<Option<(User, String)>> {
|
|
let users = Arc::clone(&self.users);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { users.find_user_by_email(txn, email).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn create_session_for_user(&self, user_id: i64) -> DomainResult<(String, String)> {
|
|
let sessions = Arc::clone(&self.sessions);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { sessions.create_session(txn, user_id).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn logout(&self, session_token: String) -> DomainResult<()> {
|
|
let sessions = Arc::clone(&self.sessions);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move { sessions.delete_session(txn, session_token).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Replaces the user's password hash with a freshly hashed new password.
|
|
/// No current-password check is performed because the account page is
|
|
/// already authenticated and this app has no email capabilities.
|
|
pub async fn change_password(&self, user_id: i64, new_password: String) -> DomainResult<()> {
|
|
let users = Arc::clone(&self.users);
|
|
let hasher = Arc::clone(&self.hasher);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
let new_hash = hasher.hash(&new_password)?;
|
|
users.update_password_hash(txn, user_id, new_hash).await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
}
|
|
|
|
pub struct ListService {
|
|
db: SqliteDatabase,
|
|
lists: Arc<dyn ListRepository>,
|
|
categories: Arc<dyn CategoryRepository>,
|
|
items: Arc<dyn ItemRepository>,
|
|
realtime: Arc<dyn RealtimeNotifier>,
|
|
}
|
|
|
|
impl ListService {
|
|
pub fn new(
|
|
db: SqliteDatabase,
|
|
lists: Arc<dyn ListRepository>,
|
|
categories: Arc<dyn CategoryRepository>,
|
|
items: Arc<dyn ItemRepository>,
|
|
realtime: Arc<dyn RealtimeNotifier>,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
lists,
|
|
categories,
|
|
items,
|
|
realtime,
|
|
}
|
|
}
|
|
|
|
pub async fn list_summaries(&self) -> DomainResult<Vec<GroceryList>> {
|
|
let lists = Arc::clone(&self.lists);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { lists.list_summaries(txn).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn create_list(&self, name: String) -> DomainResult<GroceryList> {
|
|
let lists = Arc::clone(&self.lists);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { lists.create_list(txn, name).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn get_list(&self, list_id: i64) -> DomainResult<Option<GroceryList>> {
|
|
let lists = Arc::clone(&self.lists);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { lists.get_list(txn, list_id).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn items(&self, list_id: i64) -> DomainResult<Vec<Item>> {
|
|
let items = Arc::clone(&self.items);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { items.items(txn, list_id).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn categories(&self) -> DomainResult<Vec<crate::domain::Category>> {
|
|
let categories = Arc::clone(&self.categories);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { categories.categories(txn).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn add_item(
|
|
&self,
|
|
list_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
let items = Arc::clone(&self.items);
|
|
let revision = self
|
|
.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
items
|
|
.add_item(txn, list_id, name, quantity, note, category_id)
|
|
.await
|
|
})
|
|
})
|
|
.await?;
|
|
self.realtime.publish_list_changed(list_id, revision).await;
|
|
Ok(revision)
|
|
}
|
|
|
|
pub async fn set_item_checked(
|
|
&self,
|
|
list_id: i64,
|
|
item_id: i64,
|
|
checked: bool,
|
|
) -> DomainResult<i64> {
|
|
let items = Arc::clone(&self.items);
|
|
let revision = self
|
|
.db
|
|
.run(move |txn| {
|
|
Box::pin(
|
|
async move { items.set_item_checked(txn, list_id, item_id, checked).await },
|
|
)
|
|
})
|
|
.await?;
|
|
self.realtime.publish_list_changed(list_id, revision).await;
|
|
Ok(revision)
|
|
}
|
|
|
|
pub async fn update_item(
|
|
&self,
|
|
list_id: i64,
|
|
item_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
let items = Arc::clone(&self.items);
|
|
let revision = self
|
|
.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
items
|
|
.update_item(txn, list_id, item_id, name, quantity, note, category_id)
|
|
.await
|
|
})
|
|
})
|
|
.await?;
|
|
self.realtime.publish_list_changed(list_id, revision).await;
|
|
Ok(revision)
|
|
}
|
|
|
|
pub async fn delete_item(&self, list_id: i64, item_id: i64) -> DomainResult<i64> {
|
|
let items = Arc::clone(&self.items);
|
|
let revision = self
|
|
.db
|
|
.run(move |txn| Box::pin(async move { items.delete_item(txn, list_id, item_id).await }))
|
|
.await?;
|
|
self.realtime.publish_list_changed(list_id, revision).await;
|
|
Ok(revision)
|
|
}
|
|
|
|
pub async fn create_category(&self, name: String) -> DomainResult<i64> {
|
|
let categories = Arc::clone(&self.categories);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { categories.create_category(txn, name).await }))
|
|
.await
|
|
}
|
|
}
|
|
|
|
pub struct MealService {
|
|
db: SqliteDatabase,
|
|
meals: Arc<dyn MealRepository>,
|
|
ingredients: Arc<dyn MealIngredientRepository>,
|
|
lists: Arc<dyn ListRepository>,
|
|
items: Arc<dyn ItemRepository>,
|
|
realtime: Arc<dyn RealtimeNotifier>,
|
|
}
|
|
|
|
impl MealService {
|
|
pub fn new(
|
|
db: SqliteDatabase,
|
|
meals: Arc<dyn MealRepository>,
|
|
ingredients: Arc<dyn MealIngredientRepository>,
|
|
lists: Arc<dyn ListRepository>,
|
|
items: Arc<dyn ItemRepository>,
|
|
realtime: Arc<dyn RealtimeNotifier>,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
meals,
|
|
ingredients,
|
|
lists,
|
|
items,
|
|
realtime,
|
|
}
|
|
}
|
|
|
|
pub async fn create_meal(&self, name: String, description: String) -> DomainResult<Meal> {
|
|
let meals = Arc::clone(&self.meals);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move { meals.create_meal(txn, name, description).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn get_meal(&self, meal_id: i64) -> DomainResult<Option<Meal>> {
|
|
let meals = Arc::clone(&self.meals);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { meals.get_meal(txn, meal_id).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn list_meals(&self) -> DomainResult<Vec<Meal>> {
|
|
let meals = Arc::clone(&self.meals);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { meals.list_meals(txn).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn update_meal(
|
|
&self,
|
|
meal_id: i64,
|
|
name: String,
|
|
description: String,
|
|
) -> DomainResult<()> {
|
|
let meals = Arc::clone(&self.meals);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move { meals.update_meal(txn, meal_id, name, description).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_meal(&self, meal_id: i64) -> DomainResult<()> {
|
|
let meals = Arc::clone(&self.meals);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { meals.delete_meal(txn, meal_id).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn add_ingredient(
|
|
&self,
|
|
meal_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<i64> {
|
|
let ingredients = Arc::clone(&self.ingredients);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
ingredients
|
|
.add_ingredient(txn, meal_id, name, quantity, note, category_id)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn update_ingredient(
|
|
&self,
|
|
meal_id: i64,
|
|
ingredient_id: i64,
|
|
name: String,
|
|
quantity: String,
|
|
note: String,
|
|
category_id: Option<i64>,
|
|
) -> DomainResult<()> {
|
|
let ingredients = Arc::clone(&self.ingredients);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
ingredients
|
|
.update_ingredient(
|
|
txn,
|
|
meal_id,
|
|
ingredient_id,
|
|
name,
|
|
quantity,
|
|
note,
|
|
category_id,
|
|
)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn delete_ingredient(&self, meal_id: i64, ingredient_id: i64) -> DomainResult<()> {
|
|
let ingredients = Arc::clone(&self.ingredients);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
ingredients
|
|
.delete_ingredient(txn, meal_id, ingredient_id)
|
|
.await
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Expands a meal's ingredients into items on a list in one unit of work,
|
|
/// bumping the list revision exactly once.
|
|
pub async fn add_meal_to_list(&self, meal_id: i64, list_id: i64) -> DomainResult<i64> {
|
|
let meals = Arc::clone(&self.meals);
|
|
let lists = Arc::clone(&self.lists);
|
|
let items = Arc::clone(&self.items);
|
|
let revision = self
|
|
.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
let meal = meals
|
|
.get_meal(txn, meal_id)
|
|
.await?
|
|
.ok_or(DomainError::NotFound)?;
|
|
if lists.get_list(txn, list_id).await?.is_none() {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
let new_items = meal
|
|
.ingredients
|
|
.into_iter()
|
|
.map(|ingredient| NewItem {
|
|
name: ingredient.name,
|
|
quantity: ingredient.quantity,
|
|
note: ingredient.note,
|
|
category_id: ingredient.category_id,
|
|
})
|
|
.collect();
|
|
items.add_items_bulk(txn, list_id, new_items).await
|
|
})
|
|
})
|
|
.await?;
|
|
self.realtime.publish_list_changed(list_id, revision).await;
|
|
Ok(revision)
|
|
}
|
|
}
|
|
|
|
pub struct InvitationService {
|
|
db: SqliteDatabase,
|
|
invitations: Arc<dyn InvitationRepository>,
|
|
tokens: Arc<dyn TokenGenerator>,
|
|
}
|
|
|
|
impl InvitationService {
|
|
pub fn new(
|
|
db: SqliteDatabase,
|
|
invitations: Arc<dyn InvitationRepository>,
|
|
tokens: Arc<dyn TokenGenerator>,
|
|
) -> Self {
|
|
Self {
|
|
db,
|
|
invitations,
|
|
tokens,
|
|
}
|
|
}
|
|
|
|
pub async fn create_invitation(&self, created_by: i64) -> DomainResult<String> {
|
|
let token = self.tokens.generate();
|
|
let invitations = Arc::clone(&self.invitations);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move {
|
|
invitations
|
|
.create_invitation(txn, created_by, token.clone())
|
|
.await?;
|
|
Ok(token)
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn invitation(&self, token: String) -> DomainResult<bool> {
|
|
let invitations = Arc::clone(&self.invitations);
|
|
self.db
|
|
.run(move |txn| Box::pin(async move { invitations.invitation(txn, token).await }))
|
|
.await
|
|
}
|
|
|
|
pub async fn accept_invitation(&self, token: String) -> DomainResult<()> {
|
|
let invitations = Arc::clone(&self.invitations);
|
|
self.db
|
|
.run(move |txn| {
|
|
Box::pin(async move { invitations.accept_invitation(txn, token).await })
|
|
})
|
|
.await
|
|
}
|
|
}
|