From 7c6fe7b1b7d8d041706c59d8b6696fc27617a157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:34:00 +0200 Subject: [PATCH] more refactors --- rust/src/main.rs | 155 ++-- rust/src/models/inventory.rs | 412 +++++++++ rust/src/models/mod.rs | 1361 +---------------------------- rust/src/models/trips.rs | 951 ++++++++++++++++++++ rust/src/view/inventory.rs | 24 +- rust/src/view/trip/mod.rs | 80 +- rust/src/view/trip/packagelist.rs | 6 +- rust/src/view/trip/types.rs | 2 +- 8 files changed, 1517 insertions(+), 1474 deletions(-) create mode 100644 rust/src/models/inventory.rs create mode 100644 rust/src/models/trips.rs diff --git a/rust/src/main.rs b/rust/src/main.rs index 86ee65b..cae3287 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -42,7 +42,7 @@ struct Args { pub struct ClientState { pub active_category_id: Option, pub edit_item: Option, - pub trip_edit_attribute: Option, + pub trip_edit_attribute: Option, pub trip_type_edit: Option, } @@ -279,9 +279,9 @@ async fn inventory_active( state.client_state.edit_item = inventory_query.edit_item; state.client_state.active_category_id = Some(id); - let inventory = models::Inventory::load(&state.database_pool).await?; + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - let active_category: Option<&models::Category> = state + let active_category: Option<&models::inventory::Category> = state .client_state .active_category_id .map(|id| { @@ -312,7 +312,7 @@ async fn inventory_inactive( state.client_state.edit_item = inventory_query.edit_item; state.client_state.active_category_id = None; - let inventory = models::Inventory::load(&state.database_pool).await?; + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; Ok(view::Root::build( &view::inventory::Inventory::build( @@ -346,7 +346,8 @@ async fn inventory_item_validate_name( State(state): State, Form(new_item): Form, ) -> Result { - let exists = models::InventoryItem::name_exists(&state.database_pool, &new_item.name).await?; + let exists = + models::inventory::InventoryItem::name_exists(&state.database_pool, &new_item.name).await?; Ok(view::inventory::InventoryNewItemFormName::build( Some(&new_item.name), @@ -365,7 +366,7 @@ async fn inventory_item_create( })); } - let _new_id = models::InventoryItem::save( + let _new_id = models::inventory::InventoryItem::save( &state.database_pool, &new_item.name, new_item.category_id, @@ -374,11 +375,11 @@ async fn inventory_item_create( .await?; if is_htmx(&headers) { - let inventory = models::Inventory::load(&state.database_pool).await?; + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; // it's impossible to NOT find the item here, as we literally just added // it. - let active_category: Option<&models::Category> = Some( + let active_category: Option<&models::inventory::Category> = Some( inventory .categories .iter() @@ -418,7 +419,7 @@ async fn inventory_item_delete( headers: HeaderMap, Path(id): Path, ) -> Result { - let deleted = models::InventoryItem::delete(&state.database_pool, id).await?; + let deleted = models::inventory::InventoryItem::delete(&state.database_pool, id).await?; if !deleted { Err(Error::Request(RequestError::NotFound { @@ -448,8 +449,13 @@ async fn inventory_item_edit( })); } - let id = - models::Item::update(&state.database_pool, id, &edit_item.name, edit_item.weight).await?; + let id = models::inventory::InventoryItem::update( + &state.database_pool, + id, + &edit_item.name, + edit_item.weight, + ) + .await?; Ok(Redirect::to(&format!("/inventory/category/{id}/", id = id))) } @@ -458,7 +464,7 @@ async fn inventory_item_cancel( State(state): State, Path(id): Path, ) -> Result { - let id = models::Item::find(&state.database_pool, id) + let id = models::inventory::InventoryItem::find(&state.database_pool, id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("item with id {id} not found"), @@ -466,7 +472,7 @@ async fn inventory_item_cancel( Ok(Redirect::to(&format!( "/inventory/category/{id}/", - id = id.category_id + id = id.category.id ))) } @@ -490,7 +496,7 @@ async fn trip_create( })); } - let new_id = models::Trip::save( + let new_id = models::trips::Trip::save( &state.database_pool, &new_trip.name, new_trip.date_start, @@ -502,7 +508,7 @@ async fn trip_create( } async fn trips(State(state): State) -> Result { - let trips = models::Trip::all(&state.database_pool).await?; + let trips = models::trips::Trip::all(&state.database_pool).await?; Ok(view::Root::build( &view::trip::TripManager::build(trips), @@ -512,7 +518,7 @@ async fn trips(State(state): State) -> Result, + edit: Option, category: Option, } @@ -524,12 +530,11 @@ async fn trip( state.client_state.trip_edit_attribute = trip_query.edit; state.client_state.active_category_id = trip_query.category; - let mut trip: models::Trip = - models::Trip::find(&state.database_pool, id) - .await? - .ok_or(Error::Request(RequestError::NotFound { - message: format!("trip with id {id} not found"), - }))?; + let mut trip: models::trips::Trip = models::trips::Trip::find(&state.database_pool, id) + .await? + .ok_or(Error::Request(RequestError::NotFound { + message: format!("trip with id {id} not found"), + }))?; trip.load_trips_types(&state.database_pool).await?; @@ -538,7 +543,7 @@ async fn trip( trip.load_categories(&state.database_pool).await?; - let active_category: Option<&models::TripCategory> = state + let active_category: Option<&models::trips::TripCategory> = state .client_state .active_category_id .map(|id| { @@ -565,7 +570,8 @@ async fn trip_type_remove( State(state): State, Path((trip_id, type_id)): Path<(Uuid, Uuid)>, ) -> Result { - let found = models::Trip::trip_type_remove(&state.database_pool, trip_id, type_id).await?; + let found = + models::trips::Trip::trip_type_remove(&state.database_pool, trip_id, type_id).await?; if !found { Err(Error::Request(RequestError::NotFound { @@ -580,7 +586,7 @@ async fn trip_type_add( State(state): State, Path((trip_id, type_id)): Path<(Uuid, Uuid)>, ) -> Result { - models::Trip::trip_type_add(&state.database_pool, trip_id, type_id).await?; + models::trips::Trip::trip_type_add(&state.database_pool, trip_id, type_id).await?; Ok(Redirect::to(&format!("/trips/{trip_id}/"))) } @@ -596,9 +602,12 @@ async fn trip_comment_set( Path(trip_id): Path, Form(comment_update): Form, ) -> Result { - let found = - models::Trip::set_comment(&state.database_pool, trip_id, &comment_update.new_comment) - .await?; + let found = models::trips::Trip::set_comment( + &state.database_pool, + trip_id, + &comment_update.new_comment, + ) + .await?; if !found { Err(Error::Request(RequestError::NotFound { @@ -617,17 +626,17 @@ struct TripUpdate { async fn trip_edit_attribute( State(state): State, - Path((trip_id, attribute)): Path<(Uuid, models::TripAttribute)>, + Path((trip_id, attribute)): Path<(Uuid, models::trips::TripAttribute)>, Form(trip_update): Form, ) -> Result { - if attribute == models::TripAttribute::Name { + if attribute == models::trips::TripAttribute::Name { if trip_update.new_value.is_empty() { return Err(Error::Request(RequestError::EmptyFormElement { name: "name".to_string(), })); } } - models::Trip::set_attribute( + models::trips::Trip::set_attribute( &state.database_pool, trip_id, attribute, @@ -642,10 +651,10 @@ async fn trip_item_set_state( state: &AppState, trip_id: Uuid, item_id: Uuid, - key: models::TripItemStateKey, + key: models::trips::TripItemStateKey, value: bool, ) -> Result<(), Error> { - models::TripItem::set_state(&state.database_pool, trip_id, item_id, key, value).await?; + models::trips::TripItem::set_state(&state.database_pool, trip_id, item_id, key, value).await?; Ok(()) } @@ -654,7 +663,7 @@ async fn trip_row( trip_id: Uuid, item_id: Uuid, ) -> Result { - let item = models::TripItem::find(&state.database_pool, trip_id, item_id) + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) .await? .ok_or_else(|| { Error::Request(RequestError::NotFound { @@ -665,16 +674,21 @@ async fn trip_row( let item_row = view::trip::TripItemListRow::build( trip_id, &item, - models::Item::get_category_max_weight(&state.database_pool, item.item.category_id).await?, + models::inventory::Item::get_category_max_weight( + &state.database_pool, + item.item.category_id, + ) + .await?, ); - let category = models::TripCategory::find(&state.database_pool, trip_id, item.item.category_id) - .await? - .ok_or_else(|| { - Error::Request(RequestError::NotFound { - message: format!("category with id {} not found", item.item.category_id), - }) - })?; + let category = + models::trips::TripCategory::find(&state.database_pool, trip_id, item.item.category_id) + .await? + .ok_or_else(|| { + Error::Request(RequestError::NotFound { + message: format!("category with id {} not found", item.item.category_id), + }) + })?; // TODO biggest_category_weight? let category_row = view::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true); @@ -692,7 +706,7 @@ async fn trip_item_set_pick( &state, trip_id, item_id, - models::TripItemStateKey::Pick, + models::trips::TripItemStateKey::Pick, true, ) .await?, @@ -708,7 +722,7 @@ async fn trip_item_set_pick_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pick, + models::trips::TripItemStateKey::Pick, true, ) .await?; @@ -730,7 +744,7 @@ async fn trip_item_set_unpick( &state, trip_id, item_id, - models::TripItemStateKey::Pick, + models::trips::TripItemStateKey::Pick, false, ) .await?, @@ -746,7 +760,7 @@ async fn trip_item_set_unpick_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pick, + models::trips::TripItemStateKey::Pick, false, ) .await?; @@ -768,7 +782,7 @@ async fn trip_item_set_pack( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, true, ) .await?, @@ -784,7 +798,7 @@ async fn trip_item_set_pack_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, true, ) .await?; @@ -806,7 +820,7 @@ async fn trip_item_set_unpack( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, false, ) .await?, @@ -822,7 +836,7 @@ async fn trip_item_set_unpack_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, false, ) .await?; @@ -839,7 +853,7 @@ async fn trip_total_weight_htmx( Path(trip_id): Path, ) -> Result { let total_weight = - models::Trip::find_total_picked_weight(&state.database_pool, trip_id).await?; + models::trips::Trip::find_total_picked_weight(&state.database_pool, trip_id).await?; Ok(view::trip::TripInfoTotalWeightRow::build( trip_id, total_weight, @@ -862,7 +876,8 @@ async fn inventory_category_create( })); } - let _new_id = models::Category::save(&state.database_pool, &new_category.name).await?; + let _new_id = + models::inventory::Category::save(&state.database_pool, &new_category.name).await?; Ok(Redirect::to("/inventory/")) } @@ -870,9 +885,9 @@ async fn inventory_category_create( async fn trip_state_set( State(state): State, headers: HeaderMap, - Path((trip_id, new_state)): Path<(Uuid, models::TripState)>, + Path((trip_id, new_state)): Path<(Uuid, models::trips::TripState)>, ) -> Result { - let exists = models::Trip::set_state(&state.database_pool, trip_id, &new_state).await?; + let exists = models::trips::Trip::set_state(&state.database_pool, trip_id, &new_state).await?; if !exists { return Err(Error::Request(RequestError::NotFound { @@ -905,7 +920,8 @@ async fn trips_types( ) -> Result { state.client_state.trip_type_edit = trip_type_query.edit; - let trip_types: Vec = models::TripsType::all(&state.database_pool).await?; + let trip_types: Vec = + models::trips::TripsType::all(&state.database_pool).await?; Ok(view::Root::build( &view::trip::types::TypeList::build(&state.client_state, trip_types), @@ -929,7 +945,7 @@ async fn trip_type_create( })); } - let _new_id = models::TripsType::save(&state.database_pool, &new_trip_type.name).await?; + let _new_id = models::trips::TripsType::save(&state.database_pool, &new_trip_type.name).await?; Ok(Redirect::to("/trips/types/")) } @@ -951,9 +967,12 @@ async fn trips_types_edit_name( })); } - let exists = - models::TripsType::set_name(&state.database_pool, trip_type_id, &trip_update.new_value) - .await?; + let exists = models::trips::TripsType::set_name( + &state.database_pool, + trip_type_id, + &trip_update.new_value, + ) + .await?; if !exists { return Err(Error::Request(RequestError::NotFound { @@ -968,7 +987,7 @@ async fn inventory_item( State(state): State, Path(id): Path, ) -> Result { - let item = models::InventoryItem::find(&state.database_pool, id) + let item = models::inventory::InventoryItem::find(&state.database_pool, id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("inventory item with id {id} not found"), @@ -984,7 +1003,7 @@ async fn trip_category_select( State(state): State, Path((trip_id, category_id)): Path<(Uuid, Uuid)>, ) -> Result { - let mut trip = models::Trip::find(&state.database_pool, trip_id) + let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("trip with id {trip_id} not found"), @@ -1016,9 +1035,9 @@ async fn inventory_category_select( State(state): State, Path(category_id): Path, ) -> Result { - let inventory = models::Inventory::load(&state.database_pool).await?; + let inventory = models::inventory::Inventory::load(&state.database_pool).await?; - let active_category: Option<&models::Category> = Some( + let active_category: Option<&models::inventory::Category> = Some( inventory .categories .iter() @@ -1050,7 +1069,7 @@ async fn trip_packagelist( State(state): State, Path(trip_id): Path, ) -> Result { - let mut trip = models::Trip::find(&state.database_pool, trip_id) + let mut trip = models::trips::Trip::find(&state.database_pool, trip_id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("trip with id {trip_id} not found"), @@ -1072,12 +1091,12 @@ async fn trip_item_packagelist_set_pack_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, true, ) .await?; - let item = models::TripItem::find(&state.database_pool, trip_id, item_id) + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("an item with id {item_id} does not exist"), @@ -1096,14 +1115,14 @@ async fn trip_item_packagelist_set_unpack_htmx( &state, trip_id, item_id, - models::TripItemStateKey::Pack, + models::trips::TripItemStateKey::Pack, false, ) .await?; // note that this cannot fail due to a missing item, as trip_item_set_state would already // return 404. but error handling cannot hurt ;) - let item = models::TripItem::find(&state.database_pool, trip_id, item_id) + let item = models::trips::TripItem::find(&state.database_pool, trip_id, item_id) .await? .ok_or(Error::Request(RequestError::NotFound { message: format!("an item with id {item_id} does not exist"), diff --git a/rust/src/models/inventory.rs b/rust/src/models/inventory.rs new file mode 100644 index 0000000..792b772 --- /dev/null +++ b/rust/src/models/inventory.rs @@ -0,0 +1,412 @@ +use super::Error; + +use futures::{TryFutureExt, TryStreamExt}; +use uuid::Uuid; + +pub struct Inventory { + pub categories: Vec, +} + +impl Inventory { + pub async fn load(pool: &sqlx::Pool) -> Result { + let mut categories = sqlx::query_as!( + DbCategoryRow, + "SELECT id,name,description FROM inventory_items_categories" + ) + .fetch(pool) + .map_ok(|row: DbCategoryRow| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + for category in &mut categories { + category.populate_items(pool).await?; + } + + Ok(Self { categories }) + } +} + +#[derive(Debug)] +pub struct Category { + pub id: Uuid, + pub name: String, + pub description: Option, + pub items: Option>, +} + +pub struct DbCategoryRow { + pub id: String, + pub name: String, + pub description: Option, +} + +impl TryFrom for Category { + type Error = Error; + + fn try_from(row: DbCategoryRow) -> Result { + Ok(Category { + id: Uuid::try_parse(&row.id)?, + name: row.name, + description: row.description, + items: None, + }) + } +} + +impl Category { + pub async fn _find( + pool: &sqlx::Pool, + id: Uuid, + ) -> Result, Error> { + let id_param = id.to_string(); + sqlx::query_as!( + DbCategoryRow, + "SELECT + id, + name, + description + FROM inventory_items_categories AS category + WHERE category.id = ?", + id_param, + ) + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() + } + + pub async fn save(pool: &sqlx::Pool, name: &str) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + sqlx::query!( + "INSERT INTO inventory_items_categories + (id, name) + VALUES + (?, ?)", + id_param, + name, + ) + .execute(pool) + .await?; + + Ok(id) + } + + pub fn items(&self) -> &Vec { + self.items + .as_ref() + .expect("you need to call populate_items()") + } + + pub fn total_weight(&self) -> i64 { + self.items().iter().map(|item| item.weight).sum() + } + + pub async fn populate_items(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { + let id = self.id.to_string(); + let items = sqlx::query_as!( + DbInventoryItemsRow, + "SELECT + id, + name, + weight, + description, + category_id + FROM inventory_items + WHERE category_id = ?", + id + ) + .fetch(pool) + .map_ok(|row| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + self.items = Some(items); + Ok(()) + } +} + +pub struct Product { + pub id: Uuid, + pub name: String, + pub description: Option, + pub comment: Option, +} + +pub struct inventoryItem { + pub id: Uuid, + pub name: String, + pub description: Option, + pub weight: i64, + pub category: Category, + pub product: Option, +} + +struct DbInventoryItemRow { + pub id: String, + pub name: String, + pub description: Option, + pub weight: i64, + pub category_id: String, + pub category_name: String, + pub category_description: Option, + pub product_id: Option, + pub product_name: Option, + pub product_description: Option, + pub product_comment: Option, +} + +impl TryFrom for InventoryItem { + type Error = Error; + + fn try_from(row: DbInventoryItemRow) -> Result { + Ok(InventoryItem { + id: Uuid::try_parse(&row.id)?, + name: row.name, + description: row.description, + weight: row.weight, + category: Category { + id: Uuid::try_parse(&row.category_id)?, + name: row.category_name, + description: row.category_description, + items: None, + }, + product: row + .product_id + .map(|id| -> Result { + Ok(Product { + id: Uuid::try_parse(&id)?, + name: row.product_name.unwrap(), + description: row.product_description, + comment: row.product_comment, + }) + }) + .transpose()?, + }) + } +} + +impl InventoryItem { + pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { + let id_param = id.to_string(); + + sqlx::query_as!( + DbInventoryItemRow, + "SELECT + item.id AS id, + item.name AS name, + item.description AS description, + weight, + category.id AS category_id, + category.name AS category_name, + category.description AS category_description, + product.id AS product_id, + product.name AS product_name, + product.description AS product_description, + product.comment AS product_comment + FROM inventory_items AS item + INNER JOIN inventory_items_categories as category + ON item.category_id = category.id + LEFT JOIN inventory_products AS product + ON item.product_id = product.id + WHERE item.id = ?", + id_param, + ) + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() + } + + pub async fn name_exists(pool: &sqlx::Pool, name: &str) -> Result { + Ok(sqlx::query!( + "SELECT id + FROM inventory_items + WHERE name = ?", + name, + ) + .fetch_optional(pool) + .await? + .map(|_row| ()) + .is_some()) + } + + pub async fn delete(pool: &sqlx::Pool, id: Uuid) -> Result { + let id_param = id.to_string(); + let results = sqlx::query!( + "DELETE FROM inventory_items + WHERE id = ?", + id_param + ) + .execute(pool) + .await?; + + Ok(results.rows_affected() != 0) + } + + pub async fn update( + pool: &sqlx::Pool, + id: Uuid, + name: &str, + weight: u32, + ) -> Result { + let weight = i64::try_from(weight).unwrap(); + + let id_param = id.to_string(); + Ok(sqlx::query!( + "UPDATE inventory_items AS item + SET + name = ?, + weight = ? + WHERE item.id = ? + RETURNING inventory_items.category_id AS id + ", + name, + weight, + id_param, + ) + .fetch_one(pool) + .map_ok(|row| Uuid::try_parse(&row.id.unwrap())) + .await??) + } + + pub async fn save( + pool: &sqlx::Pool, + name: &str, + category_id: Uuid, + weight: u32, + ) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + let category_id_param = category_id.to_string(); + + sqlx::query!( + "INSERT INTO inventory_items + (id, name, description, weight, category_id) + VALUES + (?, ?, ?, ?, ?)", + id_param, + name, + "", + weight, + category_id_param + ) + .execute(pool) + .await?; + + Ok(id) + } + pub async fn get_category_max_weight( + pool: &sqlx::Pool, + category_id: Uuid, + ) -> Result { + let category_id_param = category_id.to_string(); + let weight = sqlx::query!( + " + SELECT COALESCE(MAX(i_item.weight), 0) as weight + FROM inventory_items_categories as category + INNER JOIN inventory_items as i_item + ON i_item.category_id = category.id + WHERE category_id = ? + ", + category_id_param + ) + .fetch_one(pool) + .map_ok(|row| { + // convert to i64 because that the default integer type, but looks + // like COALESCE return i32? + // + // We can be certain that the row exists, as we COALESCE it + row.weight.unwrap() as i64 + }) + .await?; + + Ok(weight) + } +} + +// #[derive(Debug)] +// pub struct Item { +// pub id: Uuid, +// pub name: String, +// pub description: Option, +// pub weight: i64, +// pub category_id: Uuid, +// } + +// pub struct DbInventoryItemsRow { +// pub id: String, +// pub name: String, +// pub weight: i64, +// pub description: Option, +// pub category_id: String, +// } + +// impl TryFrom for Item { +// type Error = Error; + +// fn try_from(row: DbInventoryItemsRow) -> Result { +// Ok(Item { +// id: Uuid::try_parse(&row.id)?, +// name: row.name, +// description: row.description, // TODO +// weight: row.weight, +// category_id: Uuid::try_parse(&row.category_id)?, +// }) +// } +// } + +// impl Item { +// pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { +// let id_param = id.to_string(); +// sqlx::query_as!( +// DbInventoryItemsRow, +// "SELECT +// id, +// name, +// weight, +// description, +// category_id +// FROM inventory_items AS item +// WHERE item.id = ?", +// id_param, +// ) +// .fetch_optional(pool) +// .await? +// .map(|row| row.try_into()) +// .transpose() +// } + +// pub async fn _get_category_total_picked_weight( +// pool: &sqlx::Pool, +// category_id: Uuid, +// ) -> Result { +// let category_id_param = category_id.to_string(); +// Ok(sqlx::query!( +// " +// SELECT COALESCE(SUM(i_item.weight), 0) as weight +// FROM inventory_items_categories as category +// INNER JOIN inventory_items as i_item +// ON i_item.category_id = category.id +// INNER JOIN trips_items as t_item +// ON i_item.id = t_item.item_id +// WHERE category_id = ? +// AND t_item.pick = 1 +// ", +// category_id_param +// ) +// .fetch_one(pool) +// .map_ok(|row| { +// // convert to i64 because that the default integer type, but looks +// // like COALESCE return i32? +// // +// // We can be certain that the row exists, as we COALESCE it +// row.weight.unwrap() as i64 +// }) +// .await?) +// } +// } diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs index 6db3121..b4fd9b6 100644 --- a/rust/src/models/mod.rs +++ b/rust/src/models/mod.rs @@ -1,1361 +1,12 @@ -use serde::{Deserialize, Serialize}; -use serde_variant::to_variant_name; -use std::fmt; -use uuid::Uuid; - -use futures::TryFutureExt; -use futures::TryStreamExt; - -use time::{format_description::FormatItem, macros::format_description}; +pub mod inventory; +pub mod trips; mod error; pub use error::{DatabaseError, Error, QueryError}; -pub const DATE_FORMAT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]"); +mod consts { + use time::{format_description::FormatItem, macros::format_description}; -#[derive(sqlx::Type, PartialEq, PartialOrd, Deserialize)] -pub enum TripState { - Init, - Planning, - Planned, - Active, - Review, - Done, -} - -impl TripState { - pub fn new() -> Self { - TripState::Init - } - - pub fn next(&self) -> Option { - match self { - Self::Init => Some(Self::Planning), - Self::Planning => Some(Self::Planned), - Self::Planned => Some(Self::Active), - Self::Active => Some(Self::Review), - Self::Review => Some(Self::Done), - Self::Done => None, - } - } - - pub fn prev(&self) -> Option { - match self { - Self::Init => None, - Self::Planning => Some(Self::Init), - Self::Planned => Some(Self::Planning), - Self::Active => Some(Self::Planned), - Self::Review => Some(Self::Active), - Self::Done => Some(Self::Review), - } - } -} - -impl fmt::Display for TripState { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::Init => "Init", - Self::Planning => "Planning", - Self::Planned => "Planned", - Self::Active => "Active", - Self::Review => "Review", - Self::Done => "Done", - }, - ) - } -} - -impl std::convert::TryFrom<&str> for TripState { - type Error = Error; - - fn try_from(value: &str) -> Result { - Ok(match value { - "Init" => Self::Init, - "Planning" => Self::Planning, - "Planned" => Self::Planned, - "Active" => Self::Active, - "Review" => Self::Review, - "Done" => Self::Done, - _ => { - return Err(Error::Database(DatabaseError::Enum { - description: format!("{value} is not a valid value for TripState"), - })) - } - }) - } -} - -#[derive(Serialize, Debug)] -pub enum TripItemStateKey { - Pick, - Pack, -} - -impl fmt::Display for TripItemStateKey { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::Pick => "pick", - Self::Pack => "pack", - }, - ) - } -} - -#[derive(Debug)] -pub struct TripCategory { - pub category: Category, - pub items: Option>, -} - -impl TripCategory { - pub fn total_picked_weight(&self) -> i64 { - self.items - .as_ref() - .unwrap() - .iter() - .filter(|item| item.picked) - .map(|item| item.item.weight) - .sum() - } - - pub async fn find( - pool: &sqlx::Pool, - trip_id: Uuid, - category_id: Uuid, - ) -> Result, Error> { - let mut category: Option = None; - - let trip_id_param = trip_id.to_string(); - let category_id_param = category_id.to_string(); - - sqlx::query!( - " - SELECT - category.id as category_id, - category.name as category_name, - category.description AS category_description, - inner.trip_id AS trip_id, - inner.item_id AS item_id, - inner.item_name AS item_name, - inner.item_description AS item_description, - inner.item_weight AS item_weight, - inner.item_is_picked AS item_is_picked, - inner.item_is_packed AS item_is_packed, - inner.item_is_new AS item_is_new - FROM inventory_items_categories AS category - LEFT JOIN ( - SELECT - trip.trip_id AS trip_id, - category.id as category_id, - category.name as category_name, - category.description as category_description, - item.id as item_id, - item.name as item_name, - item.description as item_description, - item.weight as item_weight, - trip.pick as item_is_picked, - trip.pack as item_is_packed, - trip.new as item_is_new - FROM trips_items as trip - INNER JOIN inventory_items as item - ON item.id = trip.item_id - INNER JOIN inventory_items_categories as category - ON category.id = item.category_id - WHERE trip.trip_id = ? - ) AS inner - ON inner.category_id = category.id - WHERE category.id = ? - ", - trip_id_param, - category_id_param - ) - .fetch(pool) - .map_ok(|row| -> Result<(), Error> { - match &category { - Some(_) => (), - None => { - category = Some(TripCategory { - category: Category { - id: Uuid::try_parse(&row.category_id)?, - name: row.category_name, - description: row.category_description, - items: None, - }, - items: None, - }) - } - }; - - match row.item_id { - None => { - // we have an empty (unused) category which has NULL values - // for the item_id column - category.as_mut().unwrap().items = Some(vec![]); - category.as_mut().unwrap().category.items = Some(vec![]); - } - Some(item_id) => { - let item = TripItem { - item: Item { - id: Uuid::try_parse(&item_id)?, - name: row.item_name.unwrap(), - description: row.item_description, - weight: row.item_weight.unwrap(), - category_id: category.as_ref().unwrap().category.id, - }, - picked: row.item_is_picked.unwrap(), - packed: row.item_is_packed.unwrap(), - new: row.item_is_new.unwrap(), - }; - - match &mut category.as_mut().unwrap().items { - None => category.as_mut().unwrap().items = Some(vec![item]), - Some(ref mut items) => items.push(item), - } - } - } - - Ok(()) - }) - .try_collect::>>() - .await? - .into_iter() - .collect::>()?; - - // this may be None if there are no results (which - // means that the category was not found) - Ok(category) - } -} - -#[derive(Debug)] -pub struct TripItem { - pub item: Item, - pub picked: bool, - pub packed: bool, - pub new: bool, -} - -struct DbTripsItemsRow { - picked: bool, - packed: bool, - new: bool, - id: String, - name: String, - weight: i64, - description: Option, - category_id: String, -} - -impl TryFrom for TripItem { - type Error = Error; - - fn try_from(row: DbTripsItemsRow) -> Result { - Ok(TripItem { - picked: row.picked, - packed: row.packed, - new: row.new, - item: Item { - id: Uuid::try_parse(&row.id)?, - name: row.name, - description: row.description, - weight: row.weight, - category_id: Uuid::try_parse(&row.category_id)?, - }, - }) - } -} - -impl TripItem { - pub async fn find( - pool: &sqlx::Pool, - trip_id: Uuid, - item_id: Uuid, - ) -> Result, Error> { - let item_id_param = item_id.to_string(); - let trip_id_param = trip_id.to_string(); - sqlx::query_as!( - DbTripsItemsRow, - " - SELECT - t_item.item_id AS id, - t_item.pick AS picked, - t_item.pack AS packed, - t_item.new AS new, - i_item.name AS name, - i_item.description AS description, - i_item.weight AS weight, - i_item.category_id AS category_id - FROM trips_items AS t_item - INNER JOIN inventory_items AS i_item - ON i_item.id = t_item.item_id - WHERE t_item.item_id = ? - AND t_item.trip_id = ? - ", - item_id_param, - trip_id_param, - ) - .fetch_optional(pool) - .await? - .map(|row| row.try_into()) - .transpose() - } - - pub async fn set_state( - pool: &sqlx::Pool, - trip_id: Uuid, - item_id: Uuid, - key: TripItemStateKey, - value: bool, - ) -> Result<(), Error> { - let result = sqlx::query(&format!( - "UPDATE trips_items - SET {key} = ? - WHERE trip_id = ? - AND item_id = ?", - key = to_variant_name(&key).unwrap() - )) - .bind(value) - .bind(trip_id.to_string()) - .bind(item_id.to_string()) - .execute(pool) - .await?; - - (result.rows_affected() != 0).then_some(()).ok_or_else(|| { - Error::Query(QueryError::NotFound { - description: format!("item {item_id} not found for trip {trip_id}"), - }) - }) - } -} - -struct DbTripRow { - pub id: String, - pub name: String, - pub date_start: String, - pub date_end: String, - pub state: String, - pub location: Option, - pub temp_min: Option, - pub temp_max: Option, - pub comment: Option, -} - -impl TryFrom for Trip { - type Error = Error; - - fn try_from(row: DbTripRow) -> Result { - Ok(Trip { - id: Uuid::try_parse(&row.id)?, - name: row.name, - date_start: time::Date::parse(&row.date_start, DATE_FORMAT)?, - date_end: time::Date::parse(&row.date_end, DATE_FORMAT)?, - state: row.state.as_str().try_into()?, - location: row.location, - temp_min: row.temp_min, - temp_max: row.temp_max, - comment: row.comment, - types: None, - categories: None, - }) - } -} - -pub struct Trip { - pub id: Uuid, - pub name: String, - pub date_start: time::Date, - pub date_end: time::Date, - pub state: TripState, - pub location: Option, - pub temp_min: Option, - pub temp_max: Option, - pub comment: Option, - types: Option>, - categories: Option>, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum TripAttribute { - #[serde(rename = "name")] - Name, - #[serde(rename = "date_start")] - DateStart, - #[serde(rename = "date_end")] - DateEnd, - #[serde(rename = "location")] - Location, - #[serde(rename = "temp_min")] - TempMin, - #[serde(rename = "temp_max")] - TempMax, -} - -struct DbTripWeightRow { - pub total_weight: Option, -} - -impl Trip { - pub async fn all(pool: &sqlx::Pool) -> Result, Error> { - sqlx::query_as!( - DbTripRow, - "SELECT - id, - name, - CAST (date_start AS TEXT) date_start, - CAST (date_end AS TEXT) date_end, - state, - location, - temp_min, - temp_max, - comment - FROM trips", - ) - .fetch(pool) - .map_ok(|row| row.try_into()) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>() - } - - pub async fn find( - pool: &sqlx::Pool, - trip_id: Uuid, - ) -> Result, Error> { - let trip_id_param = trip_id.to_string(); - sqlx::query_as!( - DbTripRow, - "SELECT - id, - name, - CAST (date_start AS TEXT) date_start, - CAST (date_end AS TEXT) date_end, - state, - location, - temp_min, - temp_max, - comment - FROM trips - WHERE id = ?", - trip_id_param - ) - .fetch_optional(pool) - .await? - .map(|row| row.try_into()) - .transpose() - } - - pub async fn trip_type_remove( - pool: &sqlx::Pool, - id: Uuid, - type_id: Uuid, - ) -> Result { - let id_param = id.to_string(); - let type_id_param = type_id.to_string(); - - let results = sqlx::query!( - "DELETE FROM trips_to_trips_types AS ttt - WHERE ttt.trip_id = ? - AND ttt.trip_type_id = ? - ", - id_param, - type_id_param, - ) - .execute(pool) - .await?; - - Ok(results.rows_affected() != 0) - } - - pub async fn trip_type_add( - pool: &sqlx::Pool, - id: Uuid, - type_id: Uuid, - ) -> Result<(), Error> { - let trip_id_param = id.to_string(); - let type_id_param = type_id.to_string(); - sqlx::query!( - "INSERT INTO trips_to_trips_types - (trip_id, trip_type_id) - VALUES - (?, ?) - ", - trip_id_param, - type_id_param, - ) - .execute(pool) - .await?; - - Ok(()) - } - - pub async fn set_state( - pool: &sqlx::Pool, - id: Uuid, - new_state: &TripState, - ) -> Result { - let trip_id_param = id.to_string(); - let result = sqlx::query!( - "UPDATE trips - SET state = ? - WHERE id = ?", - new_state, - trip_id_param, - ) - .execute(pool) - .await?; - - Ok(result.rows_affected() != 0) - } - - pub async fn set_comment( - pool: &sqlx::Pool, - id: Uuid, - new_comment: &str, - ) -> Result { - let trip_id_param = id.to_string(); - let result = sqlx::query!( - "UPDATE trips - SET comment = ? - WHERE id = ?", - new_comment, - trip_id_param, - ) - .execute(pool) - .await?; - - Ok(result.rows_affected() != 0) - } - - pub async fn set_attribute( - pool: &sqlx::Pool, - trip_id: Uuid, - attribute: TripAttribute, - value: &str, - ) -> Result<(), Error> { - let result = sqlx::query(&format!( - "UPDATE trips - SET {attribute} = ? - WHERE id = ?", - attribute = to_variant_name(&attribute).unwrap() - )) - .bind(value) - .bind(trip_id.to_string()) - .execute(pool) - .await?; - - (result.rows_affected() != 0).then_some(()).ok_or_else(|| { - Error::Query(QueryError::NotFound { - description: format!("trip {trip_id} not found"), - }) - }) - } - - pub async fn save( - pool: &sqlx::Pool, - name: &str, - date_start: time::Date, - date_end: time::Date, - ) -> Result { - let id = Uuid::new_v4(); - let id_param = id.to_string(); - let date_start = date_start.format(DATE_FORMAT)?; - let date_end = date_end.format(DATE_FORMAT)?; - - let trip_state = TripState::new(); - - sqlx::query!( - "INSERT INTO trips - (id, name, date_start, date_end, state) - VALUES - (?, ?, ?, ?, ?)", - id_param, - name, - date_start, - date_end, - trip_state, - ) - .execute(pool) - .await?; - - Ok(id) - } - - pub async fn find_total_picked_weight( - pool: &sqlx::Pool, - trip_id: Uuid, - ) -> Result { - let trip_id_param = trip_id.to_string(); - let weight = sqlx::query_as!( - DbTripWeightRow, - " - SELECT - CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight - FROM trips AS trip - INNER JOIN trips_items AS t_item - ON t_item.trip_id = trip.id - INNER JOIN inventory_items AS i_item - ON t_item.item_id = i_item.id - WHERE - trip.id = ? - AND t_item.pick = true - ", - trip_id_param - ) - .fetch_one(pool) - .map_ok(|row| row.total_weight.unwrap() as i64) - .await?; - - Ok(weight) - } - - pub fn types(&self) -> &Vec { - self.types - .as_ref() - .expect("you need to call load_trips_types()") - } - - pub fn categories(&self) -> &Vec { - self.categories - .as_ref() - .expect("you need to call load_trips_types()") - } - - pub fn total_picked_weight(&self) -> i64 { - self.categories() - .iter() - .map(|category| -> i64 { - category - .items - .as_ref() - .unwrap() - .iter() - .filter_map(|item| Some(item.item.weight).filter(|_| item.picked)) - .sum::() - }) - .sum::() - } - - pub async fn load_trips_types(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { - let id = self.id.to_string(); - let types = sqlx::query!( - " - SELECT - type.id as id, - type.name as name, - inner.id IS NOT NULL AS active - FROM trips_types AS type - LEFT JOIN ( - SELECT type.id as id, type.name as name - FROM trips as trip - INNER JOIN trips_to_trips_types as ttt - ON ttt.trip_id = trip.id - INNER JOIN trips_types AS type - ON type.id == ttt.trip_type_id - WHERE trip.id = ? - ) AS inner - ON inner.id = type.id - ", - id - ) - .fetch(pool) - .map_ok(|row| -> Result { - Ok(TripType { - id: Uuid::try_parse(&row.id)?, - name: row.name, - active: match row.active { - 0 => false, - 1 => true, - _ => unreachable!(), - }, - }) - }) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>()?; - - self.types = Some(types); - Ok(()) - } - - pub async fn sync_trip_items_with_inventory( - &mut self, - pool: &sqlx::Pool, - ) -> Result<(), Error> { - // we need to get all items that are part of the inventory but not - // part of the trip items - // - // then, we know which items we need to sync. there are different - // states for them: - // - // * if the trip is new (it's state is INITIAL), we can just forward - // as-is - // * if the trip is new, we have to make these new items prominently - // visible so the user knows that there might be new items to - // consider - let trip_id = self.id.to_string(); - let unsynced_items: Vec = sqlx::query!( - " - SELECT - i_item.id AS item_id - FROM inventory_items AS i_item - LEFT JOIN ( - SELECT t_item.item_id as item_id - FROM trips_items AS t_item - WHERE t_item.trip_id = ? - ) AS t_item - ON t_item.item_id = i_item.id - WHERE t_item.item_id IS NULL - ", - trip_id - ) - .fetch(pool) - .map_ok(|row| -> Result { Ok(Uuid::try_parse(&row.item_id)?) }) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>()?; - - // looks like there is currently no nice way to do multiple inserts - // with sqlx. whatever, this won't matter - - // only mark as new when the trip is already underway - let mark_as_new = self.state != TripState::new(); - - for unsynced_item in &unsynced_items { - let item_id = unsynced_item.to_string(); - sqlx::query!( - " - INSERT INTO trips_items - ( - item_id, - trip_id, - pick, - pack, - new - ) - VALUES (?, ?, ?, ?, ?) - ", - item_id, - trip_id, - false, - false, - mark_as_new, - ) - .execute(pool) - .await?; - } - - tracing::info!("unsynced items: {:?}", &unsynced_items); - - Ok(()) - } - - pub async fn load_categories(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { - let mut categories: Vec = vec![]; - // we can ignore the return type as we collect into `categories` - // in the `map_ok()` closure - let id = self.id.to_string(); - sqlx::query!( - " - SELECT - category.id as category_id, - category.name as category_name, - category.description AS category_description, - inner.trip_id AS trip_id, - inner.item_id AS item_id, - inner.item_name AS item_name, - inner.item_description AS item_description, - inner.item_weight AS item_weight, - inner.item_is_picked AS item_is_picked, - inner.item_is_packed AS item_is_packed, - inner.item_is_new AS item_is_new - FROM inventory_items_categories AS category - LEFT JOIN ( - SELECT - trip.trip_id AS trip_id, - category.id as category_id, - category.name as category_name, - category.description as category_description, - item.id as item_id, - item.name as item_name, - item.description as item_description, - item.weight as item_weight, - trip.pick as item_is_picked, - trip.pack as item_is_packed, - trip.new as item_is_new - FROM trips_items as trip - INNER JOIN inventory_items as item - ON item.id = trip.item_id - INNER JOIN inventory_items_categories as category - ON category.id = item.category_id - WHERE trip.trip_id = ? - ) AS inner - ON inner.category_id = category.id - ", - id - ) - .fetch(pool) - .map_ok(|row| -> Result<(), Error> { - let mut category = TripCategory { - category: Category { - id: Uuid::try_parse(&row.category_id)?, - name: row.category_name, - description: row.category_description, - - items: None, - }, - items: None, - }; - - match row.item_id { - None => { - // we have an empty (unused) category which has NULL values - // for the item_id column - category.items = Some(vec![]); - categories.push(category); - } - Some(item_id) => { - let item = TripItem { - item: Item { - id: Uuid::try_parse(&item_id)?, - name: row.item_name.unwrap(), - description: row.item_description, - weight: row.item_weight.unwrap(), - category_id: category.category.id, - }, - picked: row.item_is_picked.unwrap(), - packed: row.item_is_packed.unwrap(), - new: row.item_is_new.unwrap(), - }; - - if let Some(&mut ref mut c) = categories - .iter_mut() - .find(|c| c.category.id == category.category.id) - { - // we always populate c.items when we add a new category, so - // it's safe to unwrap here - c.items.as_mut().unwrap().push(item); - } else { - category.items = Some(vec![item]); - categories.push(category); - } - } - } - - Ok(()) - }) - .try_collect::>>() - .await? - .into_iter() - .collect::>()?; - - self.categories = Some(categories); - - Ok(()) - } -} - -pub struct TripType { - pub id: Uuid, - pub name: String, - pub active: bool, -} - -impl TripsType { - pub async fn all(pool: &sqlx::Pool) -> Result, Error> { - sqlx::query_as!( - DbTripsTypesRow, - "SELECT - id, - name - FROM trips_types", - ) - .fetch(pool) - .map_ok(|row| row.try_into()) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>() - } - - pub async fn save(pool: &sqlx::Pool, name: &str) -> Result { - let id = Uuid::new_v4(); - let id_param = id.to_string(); - sqlx::query!( - "INSERT INTO trips_types - (id, name) - VALUES - (?, ?)", - id_param, - name, - ) - .execute(pool) - .await?; - - Ok(id) - } - - pub async fn set_name( - pool: &sqlx::Pool, - id: Uuid, - new_name: &str, - ) -> Result { - let id_param = id.to_string(); - - let result = sqlx::query!( - "UPDATE trips_types - SET name = ? - WHERE id = ?", - new_name, - id_param, - ) - .execute(pool) - .await?; - - Ok(result.rows_affected() != 0) - } -} - -struct DbCategoryRow { - pub id: String, - pub name: String, - pub description: Option, -} - -#[derive(Debug)] -pub struct Category { - pub id: Uuid, - pub name: String, - pub description: Option, - items: Option>, -} - -impl TryFrom for Category { - type Error = Error; - - fn try_from(row: DbCategoryRow) -> Result { - Ok(Category { - id: Uuid::try_parse(&row.id)?, - name: row.name, - description: row.description, - items: None, - }) - } -} - -struct DbInventoryItemsRow { - id: String, - name: String, - weight: i64, - description: Option, - category_id: String, -} - -impl Category { - pub async fn _find( - pool: &sqlx::Pool, - id: Uuid, - ) -> Result, Error> { - let id_param = id.to_string(); - sqlx::query_as!( - DbCategoryRow, - "SELECT - id, - name, - description - FROM inventory_items_categories AS category - WHERE category.id = ?", - id_param, - ) - .fetch_optional(pool) - .await? - .map(|row| row.try_into()) - .transpose() - } - - pub async fn save(pool: &sqlx::Pool, name: &str) -> Result { - let id = Uuid::new_v4(); - let id_param = id.to_string(); - sqlx::query!( - "INSERT INTO inventory_items_categories - (id, name) - VALUES - (?, ?)", - id_param, - name, - ) - .execute(pool) - .await?; - - Ok(id) - } - - pub fn items(&self) -> &Vec { - self.items - .as_ref() - .expect("you need to call populate_items()") - } - - pub fn total_weight(&self) -> i64 { - self.items().iter().map(|item| item.weight).sum() - } - - pub async fn populate_items(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { - let id = self.id.to_string(); - let items = sqlx::query_as!( - DbInventoryItemsRow, - "SELECT - id, - name, - weight, - description, - category_id - FROM inventory_items - WHERE category_id = ?", - id - ) - .fetch(pool) - .map_ok(|row| row.try_into()) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>()?; - - self.items = Some(items); - Ok(()) - } -} - -#[derive(Debug)] -pub struct Item { - pub id: Uuid, - pub name: String, - pub description: Option, - pub weight: i64, - pub category_id: Uuid, -} - -impl TryFrom for Item { - type Error = Error; - - fn try_from(row: DbInventoryItemsRow) -> Result { - Ok(Item { - id: Uuid::try_parse(&row.id)?, - name: row.name, - description: row.description, // TODO - weight: row.weight, - category_id: Uuid::try_parse(&row.category_id)?, - }) - } -} - -impl Item { - pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { - let id_param = id.to_string(); - sqlx::query_as!( - DbInventoryItemsRow, - "SELECT - id, - name, - weight, - description, - category_id - FROM inventory_items AS item - WHERE item.id = ?", - id_param, - ) - .fetch_optional(pool) - .await? - .map(|row| row.try_into()) - .transpose() - } - - pub async fn update( - pool: &sqlx::Pool, - id: Uuid, - name: &str, - weight: u32, - ) -> Result { - let weight = i64::try_from(weight).unwrap(); - - let id_param = id.to_string(); - Ok(sqlx::query!( - "UPDATE inventory_items AS item - SET - name = ?, - weight = ? - WHERE item.id = ? - RETURNING inventory_items.category_id AS id - ", - name, - weight, - id_param, - ) - .fetch_one(pool) - .map_ok(|row| Uuid::try_parse(&row.id.unwrap())) - .await??) - } - - pub async fn get_category_max_weight( - pool: &sqlx::Pool, - category_id: Uuid, - ) -> Result { - let category_id_param = category_id.to_string(); - let weight = sqlx::query!( - " - SELECT COALESCE(MAX(i_item.weight), 0) as weight - FROM inventory_items_categories as category - INNER JOIN inventory_items as i_item - ON i_item.category_id = category.id - WHERE category_id = ? - ", - category_id_param - ) - .fetch_one(pool) - .map_ok(|row| { - // convert to i64 because that the default integer type, but looks - // like COALESCE return i32? - // - // We can be certain that the row exists, as we COALESCE it - row.weight.unwrap() as i64 - }) - .await?; - - Ok(weight) - } - - pub async fn _get_category_total_picked_weight( - pool: &sqlx::Pool, - category_id: Uuid, - ) -> Result { - let category_id_param = category_id.to_string(); - Ok(sqlx::query!( - " - SELECT COALESCE(SUM(i_item.weight), 0) as weight - FROM inventory_items_categories as category - INNER JOIN inventory_items as i_item - ON i_item.category_id = category.id - INNER JOIN trips_items as t_item - ON i_item.id = t_item.item_id - WHERE category_id = ? - AND t_item.pick = 1 - ", - category_id_param - ) - .fetch_one(pool) - .map_ok(|row| { - // convert to i64 because that the default integer type, but looks - // like COALESCE return i32? - // - // We can be certain that the row exists, as we COALESCE it - row.weight.unwrap() as i64 - }) - .await?) - } -} - -struct DbTripsTypesRow { - pub id: String, - pub name: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum TripTypeAttribute { - #[serde(rename = "name")] - Name, -} - -pub struct TripsType { - pub id: Uuid, - pub name: String, -} - -impl TryFrom for TripsType { - type Error = Error; - - fn try_from(row: DbTripsTypesRow) -> Result { - Ok(TripsType { - id: Uuid::try_parse(&row.id)?, - name: row.name, - }) - } -} - -pub struct Product { - pub id: Uuid, - pub name: String, - pub description: Option, - pub comment: Option, -} - -pub struct InventoryItem { - pub id: Uuid, - pub name: String, - pub description: Option, - pub weight: i64, - pub category: Category, - pub product: Option, -} - -struct DbInventoryItemRow { - pub id: String, - pub name: String, - pub description: Option, - pub weight: i64, - pub category_id: String, - pub category_name: String, - pub category_description: Option, - pub product_id: Option, - pub product_name: Option, - pub product_description: Option, - pub product_comment: Option, -} - -impl TryFrom for InventoryItem { - type Error = Error; - - fn try_from(row: DbInventoryItemRow) -> Result { - Ok(InventoryItem { - id: Uuid::try_parse(&row.id)?, - name: row.name, - description: row.description, - weight: row.weight, - category: Category { - id: Uuid::try_parse(&row.category_id)?, - name: row.category_name, - description: row.category_description, - items: None, - }, - product: row - .product_id - .map(|id| -> Result { - Ok(Product { - id: Uuid::try_parse(&id)?, - name: row.product_name.unwrap(), - description: row.product_description, - comment: row.product_comment, - }) - }) - .transpose()?, - }) - } -} - -impl InventoryItem { - pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { - let id_param = id.to_string(); - - sqlx::query_as!( - DbInventoryItemRow, - "SELECT - item.id AS id, - item.name AS name, - item.description AS description, - weight, - category.id AS category_id, - category.name AS category_name, - category.description AS category_description, - product.id AS product_id, - product.name AS product_name, - product.description AS product_description, - product.comment AS product_comment - FROM inventory_items AS item - INNER JOIN inventory_items_categories as category - ON item.category_id = category.id - LEFT JOIN inventory_products AS product - ON item.product_id = product.id - WHERE item.id = ?", - id_param, - ) - .fetch_optional(pool) - .await? - .map(|row| row.try_into()) - .transpose() - } - - pub async fn name_exists(pool: &sqlx::Pool, name: &str) -> Result { - Ok(sqlx::query!( - "SELECT id - FROM inventory_items - WHERE name = ?", - name, - ) - .fetch_optional(pool) - .await? - .map(|_row| ()) - .is_some()) - } - - pub async fn delete(pool: &sqlx::Pool, id: Uuid) -> Result { - let id_param = id.to_string(); - let results = sqlx::query!( - "DELETE FROM inventory_items - WHERE id = ?", - id_param - ) - .execute(pool) - .await?; - - Ok(results.rows_affected() != 0) - } - - pub async fn save( - pool: &sqlx::Pool, - name: &str, - category_id: Uuid, - weight: u32, - ) -> Result { - let id = Uuid::new_v4(); - let id_param = id.to_string(); - let category_id_param = category_id.to_string(); - - sqlx::query!( - "INSERT INTO inventory_items - (id, name, description, weight, category_id) - VALUES - (?, ?, ?, ?, ?)", - id_param, - name, - "", - weight, - category_id_param - ) - .execute(pool) - .await?; - - Ok(id) - } -} - -pub struct Inventory { - pub categories: Vec, -} - -impl Inventory { - pub async fn load(pool: &sqlx::Pool) -> Result { - let mut categories = sqlx::query_as!( - DbCategoryRow, - "SELECT id,name,description FROM inventory_items_categories" - ) - .fetch(pool) - .map_ok(|row: DbCategoryRow| row.try_into()) - .try_collect::>>() - .await? - .into_iter() - .collect::, Error>>()?; - - for category in &mut categories { - category.populate_items(pool).await?; - } - - Ok(Self { categories }) - } + pub(super) const DATE_FORMAT: &[FormatItem<'static>] = + format_description!("[year]-[month]-[day]"); } diff --git a/rust/src/models/trips.rs b/rust/src/models/trips.rs new file mode 100644 index 0000000..4a0d5fc --- /dev/null +++ b/rust/src/models/trips.rs @@ -0,0 +1,951 @@ +use std::fmt; + +use super::{ + consts, + error::{DatabaseError, Error, QueryError}, + inventory, +}; + +use futures::{TryFutureExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use serde_variant::to_variant_name; +use time; +use uuid::Uuid; + +#[derive(sqlx::Type, PartialEq, PartialOrd, Deserialize)] +pub enum TripState { + Init, + Planning, + Planned, + Active, + Review, + Done, +} + +impl TripState { + pub fn new() -> Self { + TripState::Init + } + + pub fn next(&self) -> Option { + match self { + Self::Init => Some(Self::Planning), + Self::Planning => Some(Self::Planned), + Self::Planned => Some(Self::Active), + Self::Active => Some(Self::Review), + Self::Review => Some(Self::Done), + Self::Done => None, + } + } + + pub fn prev(&self) -> Option { + match self { + Self::Init => None, + Self::Planning => Some(Self::Init), + Self::Planned => Some(Self::Planning), + Self::Active => Some(Self::Planned), + Self::Review => Some(Self::Active), + Self::Done => Some(Self::Review), + } + } +} + +impl fmt::Display for TripState { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Init => "Init", + Self::Planning => "Planning", + Self::Planned => "Planned", + Self::Active => "Active", + Self::Review => "Review", + Self::Done => "Done", + }, + ) + } +} + +impl std::convert::TryFrom<&str> for TripState { + type Error = Error; + + fn try_from(value: &str) -> Result { + Ok(match value { + "Init" => Self::Init, + "Planning" => Self::Planning, + "Planned" => Self::Planned, + "Active" => Self::Active, + "Review" => Self::Review, + "Done" => Self::Done, + _ => { + return Err(Error::Database(DatabaseError::Enum { + description: format!("{value} is not a valid value for TripState"), + })) + } + }) + } +} + +#[derive(Serialize, Debug)] +pub enum TripItemStateKey { + Pick, + Pack, +} + +impl fmt::Display for TripItemStateKey { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Pick => "pick", + Self::Pack => "pack", + }, + ) + } +} + +#[derive(Debug)] +pub struct TripCategory { + pub category: inventory::Category, + pub items: Option>, +} + +impl TripCategory { + pub fn total_picked_weight(&self) -> i64 { + self.items + .as_ref() + .unwrap() + .iter() + .filter(|item| item.picked) + .map(|item| item.item.weight) + .sum() + } + + pub async fn find( + pool: &sqlx::Pool, + trip_id: Uuid, + category_id: Uuid, + ) -> Result, Error> { + let mut category: Option = None; + + let trip_id_param = trip_id.to_string(); + let category_id_param = category_id.to_string(); + + sqlx::query!( + " + SELECT + category.id as category_id, + category.name as category_name, + category.description AS category_description, + inner.trip_id AS trip_id, + inner.item_id AS item_id, + inner.item_name AS item_name, + inner.item_description AS item_description, + inner.item_weight AS item_weight, + inner.item_is_picked AS item_is_picked, + inner.item_is_packed AS item_is_packed, + inner.item_is_new AS item_is_new + FROM inventory_items_categories AS category + LEFT JOIN ( + SELECT + trip.trip_id AS trip_id, + category.id as category_id, + category.name as category_name, + category.description as category_description, + item.id as item_id, + item.name as item_name, + item.description as item_description, + item.weight as item_weight, + trip.pick as item_is_picked, + trip.pack as item_is_packed, + trip.new as item_is_new + FROM trips_items as trip + INNER JOIN inventory_items as item + ON item.id = trip.item_id + INNER JOIN inventory_items_categories as category + ON category.id = item.category_id + WHERE trip.trip_id = ? + ) AS inner + ON inner.category_id = category.id + WHERE category.id = ? + ", + trip_id_param, + category_id_param + ) + .fetch(pool) + .map_ok(|row| -> Result<(), Error> { + match &category { + Some(_) => (), + None => { + category = Some(TripCategory { + category: inventory::Category { + id: Uuid::try_parse(&row.category_id)?, + name: row.category_name, + description: row.category_description, + items: None, + }, + items: None, + }) + } + }; + + match row.item_id { + None => { + // we have an empty (unused) category which has NULL values + // for the item_id column + category.as_mut().unwrap().items = Some(vec![]); + category.as_mut().unwrap().category.items = Some(vec![]); + } + Some(item_id) => { + let item = TripItem { + item: inventory::Item { + id: Uuid::try_parse(&item_id)?, + name: row.item_name.unwrap(), + description: row.item_description, + weight: row.item_weight.unwrap(), + category_id: category.as_ref().unwrap().category.id, + }, + picked: row.item_is_picked.unwrap(), + packed: row.item_is_packed.unwrap(), + new: row.item_is_new.unwrap(), + }; + + match &mut category.as_mut().unwrap().items { + None => category.as_mut().unwrap().items = Some(vec![item]), + Some(ref mut items) => items.push(item), + } + } + } + + Ok(()) + }) + .try_collect::>>() + .await? + .into_iter() + .collect::>()?; + + // this may be None if there are no results (which + // means that the category was not found) + Ok(category) + } +} + +#[derive(Debug)] +pub struct TripItem { + pub item: inventory::Item, + pub picked: bool, + pub packed: bool, + pub new: bool, +} + +pub(crate) struct DbTripsItemsRow { + pub(crate) picked: bool, + pub(crate) packed: bool, + pub(crate) new: bool, + pub(crate) id: String, + pub(crate) name: String, + pub(crate) weight: i64, + pub(crate) description: Option, + pub(crate) category_id: String, +} + +impl TryFrom for TripItem { + type Error = Error; + + fn try_from(row: DbTripsItemsRow) -> Result { + Ok(TripItem { + picked: row.picked, + packed: row.packed, + new: row.new, + item: inventory::Item { + id: Uuid::try_parse(&row.id)?, + name: row.name, + description: row.description, + weight: row.weight, + category_id: Uuid::try_parse(&row.category_id)?, + }, + }) + } +} + +impl TripItem { + pub async fn find( + pool: &sqlx::Pool, + trip_id: Uuid, + item_id: Uuid, + ) -> Result, Error> { + let item_id_param = item_id.to_string(); + let trip_id_param = trip_id.to_string(); + sqlx::query_as!( + DbTripsItemsRow, + " + SELECT + t_item.item_id AS id, + t_item.pick AS picked, + t_item.pack AS packed, + t_item.new AS new, + i_item.name AS name, + i_item.description AS description, + i_item.weight AS weight, + i_item.category_id AS category_id + FROM trips_items AS t_item + INNER JOIN inventory_items AS i_item + ON i_item.id = t_item.item_id + WHERE t_item.item_id = ? + AND t_item.trip_id = ? + ", + item_id_param, + trip_id_param, + ) + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() + } + + pub async fn set_state( + pool: &sqlx::Pool, + trip_id: Uuid, + item_id: Uuid, + key: TripItemStateKey, + value: bool, + ) -> Result<(), Error> { + let result = sqlx::query(&format!( + "UPDATE trips_items + SET {key} = ? + WHERE trip_id = ? + AND item_id = ?", + key = to_variant_name(&key).unwrap() + )) + .bind(value) + .bind(trip_id.to_string()) + .bind(item_id.to_string()) + .execute(pool) + .await?; + + (result.rows_affected() != 0).then_some(()).ok_or_else(|| { + Error::Query(QueryError::NotFound { + description: format!("item {item_id} not found for trip {trip_id}"), + }) + }) + } +} + +pub(crate) struct DbTripRow { + pub id: String, + pub name: String, + pub date_start: String, + pub date_end: String, + pub state: String, + pub location: Option, + pub temp_min: Option, + pub temp_max: Option, + pub comment: Option, +} + +impl TryFrom for Trip { + type Error = Error; + + fn try_from(row: DbTripRow) -> Result { + Ok(Trip { + id: Uuid::try_parse(&row.id)?, + name: row.name, + date_start: time::Date::parse(&row.date_start, consts::DATE_FORMAT)?, + date_end: time::Date::parse(&row.date_end, consts::DATE_FORMAT)?, + state: row.state.as_str().try_into()?, + location: row.location, + temp_min: row.temp_min, + temp_max: row.temp_max, + comment: row.comment, + types: None, + categories: None, + }) + } +} + +pub struct Trip { + pub id: Uuid, + pub name: String, + pub date_start: time::Date, + pub date_end: time::Date, + pub state: TripState, + pub location: Option, + pub temp_min: Option, + pub temp_max: Option, + pub comment: Option, + pub(crate) types: Option>, + pub(crate) categories: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum TripAttribute { + #[serde(rename = "name")] + Name, + #[serde(rename = "date_start")] + DateStart, + #[serde(rename = "date_end")] + DateEnd, + #[serde(rename = "location")] + Location, + #[serde(rename = "temp_min")] + TempMin, + #[serde(rename = "temp_max")] + TempMax, +} + +pub(crate) struct DbTripWeightRow { + pub total_weight: Option, +} + +impl Trip { + pub async fn all(pool: &sqlx::Pool) -> Result, Error> { + sqlx::query_as!( + DbTripRow, + "SELECT + id, + name, + CAST (date_start AS TEXT) date_start, + CAST (date_end AS TEXT) date_end, + state, + location, + temp_min, + temp_max, + comment + FROM trips", + ) + .fetch(pool) + .map_ok(|row| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>() + } + + pub async fn find( + pool: &sqlx::Pool, + trip_id: Uuid, + ) -> Result, Error> { + let trip_id_param = trip_id.to_string(); + sqlx::query_as!( + DbTripRow, + "SELECT + id, + name, + CAST (date_start AS TEXT) date_start, + CAST (date_end AS TEXT) date_end, + state, + location, + temp_min, + temp_max, + comment + FROM trips + WHERE id = ?", + trip_id_param + ) + .fetch_optional(pool) + .await? + .map(|row| row.try_into()) + .transpose() + } + + pub async fn trip_type_remove( + pool: &sqlx::Pool, + id: Uuid, + type_id: Uuid, + ) -> Result { + let id_param = id.to_string(); + let type_id_param = type_id.to_string(); + + let results = sqlx::query!( + "DELETE FROM trips_to_trips_types AS ttt + WHERE ttt.trip_id = ? + AND ttt.trip_type_id = ? + ", + id_param, + type_id_param, + ) + .execute(pool) + .await?; + + Ok(results.rows_affected() != 0) + } + + pub async fn trip_type_add( + pool: &sqlx::Pool, + id: Uuid, + type_id: Uuid, + ) -> Result<(), Error> { + let trip_id_param = id.to_string(); + let type_id_param = type_id.to_string(); + sqlx::query!( + "INSERT INTO trips_to_trips_types + (trip_id, trip_type_id) + VALUES + (?, ?) + ", + trip_id_param, + type_id_param, + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn set_state( + pool: &sqlx::Pool, + id: Uuid, + new_state: &TripState, + ) -> Result { + let trip_id_param = id.to_string(); + let result = sqlx::query!( + "UPDATE trips + SET state = ? + WHERE id = ?", + new_state, + trip_id_param, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() != 0) + } + + pub async fn set_comment( + pool: &sqlx::Pool, + id: Uuid, + new_comment: &str, + ) -> Result { + let trip_id_param = id.to_string(); + let result = sqlx::query!( + "UPDATE trips + SET comment = ? + WHERE id = ?", + new_comment, + trip_id_param, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() != 0) + } + + pub async fn set_attribute( + pool: &sqlx::Pool, + trip_id: Uuid, + attribute: TripAttribute, + value: &str, + ) -> Result<(), Error> { + let result = sqlx::query(&format!( + "UPDATE trips + SET {attribute} = ? + WHERE id = ?", + attribute = to_variant_name(&attribute).unwrap() + )) + .bind(value) + .bind(trip_id.to_string()) + .execute(pool) + .await?; + + (result.rows_affected() != 0).then_some(()).ok_or_else(|| { + Error::Query(QueryError::NotFound { + description: format!("trip {trip_id} not found"), + }) + }) + } + + pub async fn save( + pool: &sqlx::Pool, + name: &str, + date_start: time::Date, + date_end: time::Date, + ) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + let date_start = date_start.format(consts::DATE_FORMAT)?; + let date_end = date_end.format(consts::DATE_FORMAT)?; + + let trip_state = TripState::new(); + + sqlx::query!( + "INSERT INTO trips + (id, name, date_start, date_end, state) + VALUES + (?, ?, ?, ?, ?)", + id_param, + name, + date_start, + date_end, + trip_state, + ) + .execute(pool) + .await?; + + Ok(id) + } + + pub async fn find_total_picked_weight( + pool: &sqlx::Pool, + trip_id: Uuid, + ) -> Result { + let trip_id_param = trip_id.to_string(); + let weight = sqlx::query_as!( + DbTripWeightRow, + " + SELECT + CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight + FROM trips AS trip + INNER JOIN trips_items AS t_item + ON t_item.trip_id = trip.id + INNER JOIN inventory_items AS i_item + ON t_item.item_id = i_item.id + WHERE + trip.id = ? + AND t_item.pick = true + ", + trip_id_param + ) + .fetch_one(pool) + .map_ok(|row| row.total_weight.unwrap() as i64) + .await?; + + Ok(weight) + } + + pub fn types(&self) -> &Vec { + self.types + .as_ref() + .expect("you need to call load_trips_types()") + } + + pub fn categories(&self) -> &Vec { + self.categories + .as_ref() + .expect("you need to call load_trips_types()") + } + + pub fn total_picked_weight(&self) -> i64 { + self.categories() + .iter() + .map(|category| -> i64 { + category + .items + .as_ref() + .unwrap() + .iter() + .filter_map(|item| Some(item.item.weight).filter(|_| item.picked)) + .sum::() + }) + .sum::() + } + + pub async fn load_trips_types(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { + let id = self.id.to_string(); + let types = sqlx::query!( + " + SELECT + type.id as id, + type.name as name, + inner.id IS NOT NULL AS active + FROM trips_types AS type + LEFT JOIN ( + SELECT type.id as id, type.name as name + FROM trips as trip + INNER JOIN trips_to_trips_types as ttt + ON ttt.trip_id = trip.id + INNER JOIN trips_types AS type + ON type.id == ttt.trip_type_id + WHERE trip.id = ? + ) AS inner + ON inner.id = type.id + ", + id + ) + .fetch(pool) + .map_ok(|row| -> Result { + Ok(TripType { + id: Uuid::try_parse(&row.id)?, + name: row.name, + active: match row.active { + 0 => false, + 1 => true, + _ => unreachable!(), + }, + }) + }) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + self.types = Some(types); + Ok(()) + } + + pub async fn sync_trip_items_with_inventory( + &mut self, + pool: &sqlx::Pool, + ) -> Result<(), Error> { + // we need to get all items that are part of the inventory but not + // part of the trip items + // + // then, we know which items we need to sync. there are different + // states for them: + // + // * if the trip is new (it's state is INITIAL), we can just forward + // as-is + // * if the trip is new, we have to make these new items prominently + // visible so the user knows that there might be new items to + // consider + let trip_id = self.id.to_string(); + let unsynced_items: Vec = sqlx::query!( + " + SELECT + i_item.id AS item_id + FROM inventory_items AS i_item + LEFT JOIN ( + SELECT t_item.item_id as item_id + FROM trips_items AS t_item + WHERE t_item.trip_id = ? + ) AS t_item + ON t_item.item_id = i_item.id + WHERE t_item.item_id IS NULL + ", + trip_id + ) + .fetch(pool) + .map_ok(|row| -> Result { Ok(Uuid::try_parse(&row.item_id)?) }) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + // looks like there is currently no nice way to do multiple inserts + // with sqlx. whatever, this won't matter + + // only mark as new when the trip is already underway + let mark_as_new = self.state != TripState::new(); + + for unsynced_item in &unsynced_items { + let item_id = unsynced_item.to_string(); + sqlx::query!( + " + INSERT INTO trips_items + ( + item_id, + trip_id, + pick, + pack, + new + ) + VALUES (?, ?, ?, ?, ?) + ", + item_id, + trip_id, + false, + false, + mark_as_new, + ) + .execute(pool) + .await?; + } + + tracing::info!("unsynced items: {:?}", &unsynced_items); + + Ok(()) + } + + pub async fn load_categories(&mut self, pool: &sqlx::Pool) -> Result<(), Error> { + let mut categories: Vec = vec![]; + // we can ignore the return type as we collect into `categories` + // in the `map_ok()` closure + let id = self.id.to_string(); + sqlx::query!( + " + SELECT + category.id as category_id, + category.name as category_name, + category.description AS category_description, + inner.trip_id AS trip_id, + inner.item_id AS item_id, + inner.item_name AS item_name, + inner.item_description AS item_description, + inner.item_weight AS item_weight, + inner.item_is_picked AS item_is_picked, + inner.item_is_packed AS item_is_packed, + inner.item_is_new AS item_is_new + FROM inventory_items_categories AS category + LEFT JOIN ( + SELECT + trip.trip_id AS trip_id, + category.id as category_id, + category.name as category_name, + category.description as category_description, + item.id as item_id, + item.name as item_name, + item.description as item_description, + item.weight as item_weight, + trip.pick as item_is_picked, + trip.pack as item_is_packed, + trip.new as item_is_new + FROM trips_items as trip + INNER JOIN inventory_items as item + ON item.id = trip.item_id + INNER JOIN inventory_items_categories as category + ON category.id = item.category_id + WHERE trip.trip_id = ? + ) AS inner + ON inner.category_id = category.id + ", + id + ) + .fetch(pool) + .map_ok(|row| -> Result<(), Error> { + let mut category = TripCategory { + category: inventory::Category { + id: Uuid::try_parse(&row.category_id)?, + name: row.category_name, + description: row.category_description, + + items: None, + }, + items: None, + }; + + match row.item_id { + None => { + // we have an empty (unused) category which has NULL values + // for the item_id column + category.items = Some(vec![]); + categories.push(category); + } + Some(item_id) => { + let item = TripItem { + item: inventory::Item { + id: Uuid::try_parse(&item_id)?, + name: row.item_name.unwrap(), + description: row.item_description, + weight: row.item_weight.unwrap(), + category_id: category.category.id, + }, + picked: row.item_is_picked.unwrap(), + packed: row.item_is_packed.unwrap(), + new: row.item_is_new.unwrap(), + }; + + if let Some(&mut ref mut c) = categories + .iter_mut() + .find(|c| c.category.id == category.category.id) + { + // we always populate c.items when we add a new category, so + // it's safe to unwrap here + c.items.as_mut().unwrap().push(item); + } else { + category.items = Some(vec![item]); + categories.push(category); + } + } + } + + Ok(()) + }) + .try_collect::>>() + .await? + .into_iter() + .collect::>()?; + + self.categories = Some(categories); + + Ok(()) + } +} + +pub struct TripType { + pub id: Uuid, + pub name: String, + pub active: bool, +} + +impl TripsType { + pub async fn all(pool: &sqlx::Pool) -> Result, Error> { + sqlx::query_as!( + DbTripsTypesRow, + "SELECT + id, + name + FROM trips_types", + ) + .fetch(pool) + .map_ok(|row| row.try_into()) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>() + } + + pub async fn save(pool: &sqlx::Pool, name: &str) -> Result { + let id = Uuid::new_v4(); + let id_param = id.to_string(); + sqlx::query!( + "INSERT INTO trips_types + (id, name) + VALUES + (?, ?)", + id_param, + name, + ) + .execute(pool) + .await?; + + Ok(id) + } + + pub async fn set_name( + pool: &sqlx::Pool, + id: Uuid, + new_name: &str, + ) -> Result { + let id_param = id.to_string(); + + let result = sqlx::query!( + "UPDATE trips_types + SET name = ? + WHERE id = ?", + new_name, + id_param, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() != 0) + } +} + +pub(crate) struct DbTripsTypesRow { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum TripTypeAttribute { + #[serde(rename = "name")] + Name, +} + +pub struct TripsType { + pub id: Uuid, + pub name: String, +} + +impl TryFrom for TripsType { + type Error = Error; + + fn try_from(row: DbTripsTypesRow) -> Result { + Ok(TripsType { + id: Uuid::try_parse(&row.id)?, + name: row.name, + }) + } +} diff --git a/rust/src/view/inventory.rs b/rust/src/view/inventory.rs index eb5035d..9cae781 100644 --- a/rust/src/view/inventory.rs +++ b/rust/src/view/inventory.rs @@ -8,8 +8,8 @@ pub struct Inventory; impl Inventory { pub fn build( - active_category: Option<&models::Category>, - categories: &Vec, + active_category: Option<&models::inventory::Category>, + categories: &Vec, edit_item_id: Option, ) -> Markup { html!( @@ -37,12 +37,12 @@ pub struct InventoryCategoryList; impl InventoryCategoryList { pub fn build( - active_category: Option<&models::Category>, - categories: &Vec, + active_category: Option<&models::inventory::Category>, + categories: &Vec, ) -> Markup { let biggest_category_weight: i64 = categories .iter() - .map(models::Category::total_weight) + .map(models::inventory::Category::total_weight) .max() .unwrap_or(1); @@ -129,7 +129,7 @@ impl InventoryCategoryList { } td ."border" ."p-0" ."m-0" { p ."p-2" ."m-2" { - (categories.iter().map(models::Category::total_weight).sum::().to_string()) + (categories.iter().map(models::inventory::Category::total_weight).sum::().to_string()) } } } @@ -142,7 +142,7 @@ impl InventoryCategoryList { pub struct InventoryItemList; impl InventoryItemList { - pub fn build(edit_item_id: Option, items: &Vec) -> Markup { + pub fn build(edit_item_id: Option, items: &Vec) -> Markup { let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1); html!( div #items { @@ -414,8 +414,8 @@ pub struct InventoryNewItemFormCategory; impl InventoryNewItemFormCategory { pub fn build( - active_category: Option<&models::Category>, - categories: &Vec, + active_category: Option<&models::inventory::Category>, + categories: &Vec, ) -> Markup { html!( div @@ -455,8 +455,8 @@ pub struct InventoryNewItemForm; impl InventoryNewItemForm { pub fn build( - active_category: Option<&models::Category>, - categories: &Vec, + active_category: Option<&models::inventory::Category>, + categories: &Vec, ) -> Markup { html!( form @@ -558,7 +558,7 @@ impl InventoryNewCategoryForm { pub struct InventoryItem; impl InventoryItem { - pub fn build(_state: &ClientState, item: &models::InventoryItem) -> Markup { + pub fn build(_state: &ClientState, item: &models::inventory::InventoryItem) -> Markup { html!( div ."p-8" { table diff --git a/rust/src/view/trip/mod.rs b/rust/src/view/trip/mod.rs index 0962f96..053114d 100644 --- a/rust/src/view/trip/mod.rs +++ b/rust/src/view/trip/mod.rs @@ -12,7 +12,7 @@ pub mod packagelist; pub mod types; impl TripManager { - pub fn build(trips: Vec) -> Markup { + pub fn build(trips: Vec) -> Markup { html!( div ."p-8" @@ -44,21 +44,21 @@ impl From for &'static str { } } -fn trip_state_icon(state: &models::TripState) -> &'static str { +fn trip_state_icon(state: &models::trips::TripState) -> &'static str { match state { - models::TripState::Init => "mdi-magic-staff", - models::TripState::Planning => "mdi-text-box-outline", - models::TripState::Planned => "mdi-clock-outline", - models::TripState::Active => "mdi-play", - models::TripState::Review => "mdi-magnify", - models::TripState::Done => "mdi-check", + models::trips::TripState::Init => "mdi-magic-staff", + models::trips::TripState::Planning => "mdi-text-box-outline", + models::trips::TripState::Planned => "mdi-clock-outline", + models::trips::TripState::Active => "mdi-play", + models::trips::TripState::Review => "mdi-magnify", + models::trips::TripState::Done => "mdi-check", } } pub struct TripTable; impl TripTable { - pub fn build(trips: Vec) -> Markup { + pub fn build(trips: Vec) -> Markup { html!( table ."table" @@ -222,9 +222,9 @@ pub struct Trip; impl Trip { pub fn build( - trip: &models::Trip, - trip_edit_attribute: Option, - active_category: Option<&models::TripCategory>, + trip: &models::trips::Trip, + trip_edit_attribute: Option, + active_category: Option<&models::trips::TripCategory>, ) -> Markup { html!( div ."p-8" ."flex" ."flex-col" ."gap-8" { @@ -257,10 +257,10 @@ impl Trip { } } div ."flex" ."flex-row" ."items-center" ."gap-x-3" { - @if trip_edit_attribute.as_ref().map_or(false, |a| *a == models::TripAttribute::Name) { + @if trip_edit_attribute.as_ref().map_or(false, |a| *a == models::trips::TripAttribute::Name) { form id="edit-trip" - action=(format!("edit/{}/submit", to_variant_name(&models::TripAttribute::Name).unwrap())) + action=(format!("edit/{}/submit", to_variant_name(&models::trips::TripAttribute::Name).unwrap())) hx-boost="true" target="_self" method="post" @@ -316,7 +316,7 @@ impl Trip { h1 ."text-2xl" { (trip.name) } span { a - href={"?edit=" (to_variant_name(&models::TripAttribute::Name).unwrap())} + href={"?edit=" (to_variant_name(&models::trips::TripAttribute::Name).unwrap())} hx-boost="true" { span @@ -357,8 +357,8 @@ impl TripInfoRow { pub fn build( name: &str, value: Option, - attribute_key: &models::TripAttribute, - edit_attribute: Option<&models::TripAttribute>, + attribute_key: &models::trips::TripAttribute, + edit_attribute: Option<&models::trips::TripAttribute>, input_type: InputType, ) -> Markup { let edit = edit_attribute.map_or(false, |a| a == attribute_key); @@ -497,7 +497,7 @@ impl TripInfoTotalWeightRow { pub struct TripInfoStateRow; impl TripInfoStateRow { - pub fn build(trip_state: &models::TripState) -> Markup { + pub fn build(trip_state: &models::trips::TripState) -> Markup { let prev_state = trip_state.prev(); let next_state = trip_state.next(); html!( @@ -602,8 +602,8 @@ pub struct TripInfo; impl TripInfo { pub fn build( - trip_edit_attribute: Option, - trip: &models::Trip, + trip_edit_attribute: Option, + trip: &models::trips::Trip, ) -> Markup { html!( table @@ -617,31 +617,31 @@ impl TripInfo { tbody { (TripInfoRow::build("Location", trip.location.as_ref(), - &models::TripAttribute::Location, + &models::trips::TripAttribute::Location, trip_edit_attribute.as_ref(), InputType::Text, )) (TripInfoRow::build("Start date", Some(trip.date_start), - &models::TripAttribute::DateStart, + &models::trips::TripAttribute::DateStart, trip_edit_attribute.as_ref(), InputType::Date, )) (TripInfoRow::build("End date", Some(trip.date_end), - &models::TripAttribute::DateEnd, + &models::trips::TripAttribute::DateEnd, trip_edit_attribute.as_ref(), InputType::Date, )) (TripInfoRow::build("Temp (min)", trip.temp_min, - &models::TripAttribute::TempMin, + &models::trips::TripAttribute::TempMin, trip_edit_attribute.as_ref(), InputType::Number, )) (TripInfoRow::build("Temp (max)", trip.temp_max, - &models::TripAttribute::TempMax, + &models::trips::TripAttribute::TempMax, trip_edit_attribute.as_ref(), InputType::Number, )) @@ -672,8 +672,8 @@ impl TripInfo { // the margins { @let types = trip.types(); - @let active_triptypes = types.iter().filter(|t| t.active).collect::>(); - @let inactive_triptypes = types.iter().filter(|t| !t.active).collect::>(); + @let active_triptypes = types.iter().filter(|t| t.active).collect::>(); + @let inactive_triptypes = types.iter().filter(|t| !t.active).collect::>(); @if !active_triptypes.is_empty() { div @@ -775,7 +775,7 @@ impl TripInfo { pub struct TripComment; impl TripComment { - pub fn build(trip: &models::Trip) -> Markup { + pub fn build(trip: &models::trips::Trip) -> Markup { html!( div x-data="{ save_active: false }" @@ -830,7 +830,10 @@ impl TripComment { pub struct TripItems; impl TripItems { - pub fn build(active_category: Option<&models::TripCategory>, trip: &models::Trip) -> Markup { + pub fn build( + active_category: Option<&models::trips::TripCategory>, + trip: &models::trips::Trip, + ) -> Markup { html!( div #trip-items ."grid" ."grid-cols-4" ."gap-3" { div ."col-span-2" { @@ -856,7 +859,7 @@ pub struct TripCategoryListRow; impl TripCategoryListRow { pub fn build( trip_id: Uuid, - category: &models::TripCategory, + category: &models::trips::TripCategory, active: bool, biggest_category_weight: i64, htmx_swap: bool, @@ -962,12 +965,15 @@ impl TripCategoryListRow { pub struct TripCategoryList; impl TripCategoryList { - pub fn build(active_category: Option<&models::TripCategory>, trip: &models::Trip) -> Markup { + pub fn build( + active_category: Option<&models::trips::TripCategory>, + trip: &models::trips::Trip, + ) -> Markup { let categories = trip.categories(); let biggest_category_weight: i64 = categories .iter() - .map(models::TripCategory::total_picked_weight) + .map(models::trips::TripCategory::total_picked_weight) .max() .unwrap_or(1); @@ -1002,7 +1008,7 @@ impl TripCategoryList { } td ."border" ."p-0" ."m-0" { p ."p-2" ."m-2" { - (categories.iter().map(models::TripCategory::total_picked_weight).sum::().to_string()) + (categories.iter().map(models::trips::TripCategory::total_picked_weight).sum::().to_string()) } } } @@ -1015,7 +1021,7 @@ impl TripCategoryList { pub struct TripItemList; impl TripItemList { - pub fn build(trip_id: Uuid, items: &Vec) -> Markup { + pub fn build(trip_id: Uuid, items: &Vec) -> Markup { let biggest_item_weight: i64 = items.iter().map(|item| item.item.weight).max().unwrap_or(1); html!( @@ -1053,7 +1059,11 @@ impl TripItemList { pub struct TripItemListRow; impl TripItemListRow { - pub fn build(trip_id: Uuid, item: &models::TripItem, biggest_item_weight: i64) -> Markup { + pub fn build( + trip_id: Uuid, + item: &models::trips::TripItem, + biggest_item_weight: i64, + ) -> Markup { html!( tr ."h-10" { td diff --git a/rust/src/view/trip/packagelist.rs b/rust/src/view/trip/packagelist.rs index 5a21ed8..cc7aff2 100644 --- a/rust/src/view/trip/packagelist.rs +++ b/rust/src/view/trip/packagelist.rs @@ -6,7 +6,7 @@ use crate::models; pub struct TripPackageListRow; impl TripPackageListRow { - pub fn build(trip_id: Uuid, item: &models::TripItem) -> Markup { + pub fn build(trip_id: Uuid, item: &models::trips::TripItem) -> Markup { html!( li ."flex" @@ -82,7 +82,7 @@ impl TripPackageListRow { pub struct TripPackageListCategoryBlock; impl TripPackageListCategoryBlock { - pub fn build(trip: &models::Trip, category: &models::TripCategory) -> Markup { + pub fn build(trip: &models::trips::Trip, category: &models::trips::TripCategory) -> Markup { let empty = !category .items .as_ref() @@ -138,7 +138,7 @@ impl TripPackageListCategoryBlock { pub struct TripPackageList; impl TripPackageList { - pub fn build(trip: &models::Trip) -> Markup { + pub fn build(trip: &models::trips::Trip) -> Markup { // let all_packed = trip.categories().iter().all(|category| { // category // .items diff --git a/rust/src/view/trip/types.rs b/rust/src/view/trip/types.rs index 0b1bbd2..1258f06 100644 --- a/rust/src/view/trip/types.rs +++ b/rust/src/view/trip/types.rs @@ -5,7 +5,7 @@ use maud::{html, Markup}; pub struct TypeList; impl TypeList { - pub fn build(state: &ClientState, trip_types: Vec) -> Markup { + pub fn build(state: &ClientState, trip_types: Vec) -> Markup { html!( div ."p-8" ."flex" ."flex-col" ."gap-8" { h1 ."text-2xl" {"Trip Types"}