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, sessions: Arc, invitations: Arc, hasher: Arc, registration_mode: RegistrationMode, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum RegistrationMode { Open, InviteOnly, } impl AuthService { pub fn new( db: SqliteDatabase, users: Arc, sessions: Arc, invitations: Arc, hasher: Arc, registration_mode: RegistrationMode, ) -> Self { Self { db, users, sessions, invitations, hasher, registration_mode, } } pub async fn can_register(&self, invite: Option<&str>) -> DomainResult { 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> { 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> { 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> { 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, categories: Arc, items: Arc, realtime: Arc, } impl ListService { pub fn new( db: SqliteDatabase, lists: Arc, categories: Arc, items: Arc, realtime: Arc, ) -> Self { Self { db, lists, categories, items, realtime, } } pub async fn list_summaries(&self) -> DomainResult> { 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 { 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> { 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> { 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> { 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, ) -> DomainResult { 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 { 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, ) -> DomainResult { 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 { 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 { 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, ingredients: Arc, lists: Arc, items: Arc, realtime: Arc, } impl MealService { pub fn new( db: SqliteDatabase, meals: Arc, ingredients: Arc, lists: Arc, items: Arc, realtime: Arc, ) -> Self { Self { db, meals, ingredients, lists, items, realtime, } } pub async fn create_meal(&self, name: String, description: String) -> DomainResult { 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> { 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> { 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, ) -> DomainResult { 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, ) -> 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 { 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, tokens: Arc, } impl InvitationService { pub fn new( db: SqliteDatabase, invitations: Arc, tokens: Arc, ) -> Self { Self { db, invitations, tokens, } } pub async fn create_invitation(&self, created_by: i64) -> DomainResult { 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 { 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 } }