Files
sustenance/src/services.rs
T
sbstp a6482ddb0e
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful
reset/update password
2026-08-03 11:33:24 -04:00

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
}
}