From 60a1945fe898db0ed7af4bd7791553641e8f29d3 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Fri, 11 Nov 2022 17:35:10 -0500 Subject: [PATCH] Store inventory details --- .../20221119220732_inventory.down.sql | 3 + .../20221119220732_inventory.up.sql | 17 +++ kitchen/sqlx-data.json | 90 +++++++++++++- kitchen/src/web/mod.rs | 64 +++++++++- .../fetch_inventory_filtered_ingredients.sql | 1 + .../storage/fetch_inventory_modified_amts.sql | 1 + kitchen/src/web/storage/mod.rs | 117 +++++++++++++++++- .../save_inventory_filtered_ingredients.sql | 2 + .../storage/save_inventory_modified_amts.sql | 2 + recipes/src/lib.rs | 20 ++- web/src/api.rs | 51 +++++++- web/src/app_state.rs | 27 +++- web/src/components/shopping_list.rs | 35 ++++-- 13 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 kitchen/migrations/20221119220732_inventory.down.sql create mode 100644 kitchen/migrations/20221119220732_inventory.up.sql create mode 100644 kitchen/src/web/storage/fetch_inventory_filtered_ingredients.sql create mode 100644 kitchen/src/web/storage/fetch_inventory_modified_amts.sql create mode 100644 kitchen/src/web/storage/save_inventory_filtered_ingredients.sql create mode 100644 kitchen/src/web/storage/save_inventory_modified_amts.sql diff --git a/kitchen/migrations/20221119220732_inventory.down.sql b/kitchen/migrations/20221119220732_inventory.down.sql new file mode 100644 index 0000000..fca4ecf --- /dev/null +++ b/kitchen/migrations/20221119220732_inventory.down.sql @@ -0,0 +1,3 @@ +-- Add down migration script here +delete table filtered_ingredients; +delete table modified_amts; \ No newline at end of file diff --git a/kitchen/migrations/20221119220732_inventory.up.sql b/kitchen/migrations/20221119220732_inventory.up.sql new file mode 100644 index 0000000..917882e --- /dev/null +++ b/kitchen/migrations/20221119220732_inventory.up.sql @@ -0,0 +1,17 @@ +-- Add up migration script here +create table filtered_ingredients( + user_id TEXT NOT NULL, + name TEXT NOT NULL, + form TEXT NOT NULL, + measure_type TEXT NOT NULL, + primary key(user_id, name, form, measure_type) +); + +create table modified_amts( + user_id TEXT NOT NULL, + name TEXT NOT NULL, + form TEXT NOT NULL, + measure_type TEXT NOT NULL, + amt TEXT NOT NULL, + primary key(user_id, name, form, measure_type) +); \ No newline at end of file diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 2343e07..0e8c5f8 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -1,5 +1,15 @@ { "db": "SQLite", + "0e06f6e072e2c55769feda0d5f998509139097fee640caf8fb38c7087669bee4": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 4 + } + }, + "query": "insert into filtered_ingredients(user_id, name, form, measure_type)\n values (?, ?, ?, ?)" + }, "104f07472670436d3eee1733578bbf0c92dc4f965d3d13f9bf4bfbc92958c5b6": { "describe": { "columns": [ @@ -48,7 +58,7 @@ { "name": "plan_date: NaiveDate", "ordinal": 0, - "type_info": "Text" + "type_info": "Date" }, { "name": "recipe_id", @@ -82,6 +92,16 @@ }, "query": "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)\n on conflict(user_id, recipe_id) do update set recipe_text=excluded.recipe_text" }, + "512003bd6ef47567b243bbcaefcbd220acf36efab4cbd43b1a8debe59593577c": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 5 + } + }, + "query": "insert into modified_amts(user_id, name, form, measure_type, amt)\n values (?, ?, ?, ?, ?)" + }, "5d743897fb0d8fd54c3708f1b1c6e416346201faa9e28823c1ba5a421472b1fa": { "describe": { "columns": [], @@ -170,7 +190,7 @@ { "name": "plan_date: NaiveDate", "ordinal": 0, - "type_info": "Text" + "type_info": "Date" }, { "name": "recipe_id", @@ -222,6 +242,36 @@ }, "query": "select category_text from categories where user_id = ?" }, + "d7d94a87b0153d1436eac0f6db820f25594e94decc8d740037c10802aa49157f": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "form", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "measure_type", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select name, form, measure_type from filtered_ingredients where user_id = ?" + }, "d84685a82585c5e4ae72c86ba1fe6e4a7241c4c3c9e948213e5849d956132bad": { "describe": { "columns": [], @@ -231,5 +281,41 @@ } }, "query": "delete from sessions" + }, + "fc294739374d2a791214f747095e0bf9378989d1ff07d96a5431dbb208f21951": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "form", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "measure_type", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "amt", + "ordinal": 3, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select name, form, measure_type, amt from modified_amts where user_id = ?;" } } \ No newline at end of file diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 959024e..136b374 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; // Copyright 2022 Jeremy Wall // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -11,9 +12,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use std::{collections::BTreeSet, net::SocketAddr}; use axum::{ body::{boxed, Full}, @@ -23,11 +24,11 @@ use axum::{ routing::{get, Router}, }; use mime_guess; -use recipes::RecipeEntry; +use recipes::{IngredientKey, RecipeEntry}; use rust_embed::RustEmbed; use tower::ServiceBuilder; use tower_http::trace::TraceLayer; -use tracing::{debug, info, instrument}; +use tracing::{debug, error, info, instrument}; use storage::{APIStore, AuthStore}; @@ -263,6 +264,56 @@ async fn api_save_plan( } } +async fn api_inventory( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, +) -> impl IntoResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + match app_store.fetch_inventory_data(id).await { + Ok(tpl) => Ok(axum::Json::from(tpl)), + Err(e) => { + error!(err=?e); + Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e))) + } + } + } else { + Err(( + StatusCode::UNAUTHORIZED, + "You must be authorized to use this API call".to_owned(), + )) + } +} + +async fn api_save_inventory( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Json((filtered_ingredients, modified_amts)): Json<( + BTreeSet, + BTreeMap, + )>, +) -> impl IntoResponse { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(id)) = session { + if let Err(e) = app_store + .save_inventory_data(id, filtered_ingredients, modified_amts) + .await + { + error!(err=?e); + return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)); + } + ( + StatusCode::OK, + "Successfully saved inventory data".to_owned(), + ) + } else { + ( + StatusCode::UNAUTHORIZED, + "You must be authorized to use this API call".to_owned(), + ) + } +} + #[instrument(fields(recipe_dir=?recipe_dir_path,listen=?listen_socket), skip_all)] pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socket: SocketAddr) { let store = Arc::new(storage::file_store::AsyncFileStore::new( @@ -281,9 +332,16 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke .route("/api/v1/recipes", get(api_recipes).post(api_save_recipes)) // recipe entry api path route .route("/api/v1/recipe/:recipe_id", get(api_recipe_entry)) + // TODO(jwall): We should use route_layer to enforce the authorization + // requirements here. // mealplan api path routes .route("/api/v1/plan", get(api_plan).post(api_save_plan)) .route("/api/v1/plan/:date", get(api_plan_since)) + // Inventory api path route + .route( + "/api/v1/inventory", + get(api_inventory).post(api_save_inventory), + ) // categories api path route .route( "/api/v1/categories", diff --git a/kitchen/src/web/storage/fetch_inventory_filtered_ingredients.sql b/kitchen/src/web/storage/fetch_inventory_filtered_ingredients.sql new file mode 100644 index 0000000..9ba0497 --- /dev/null +++ b/kitchen/src/web/storage/fetch_inventory_filtered_ingredients.sql @@ -0,0 +1 @@ +select name, form, measure_type from filtered_ingredients where user_id = ? \ No newline at end of file diff --git a/kitchen/src/web/storage/fetch_inventory_modified_amts.sql b/kitchen/src/web/storage/fetch_inventory_modified_amts.sql new file mode 100644 index 0000000..6903a6a --- /dev/null +++ b/kitchen/src/web/storage/fetch_inventory_modified_amts.sql @@ -0,0 +1 @@ +select name, form, measure_type, amt from modified_amts where user_id = ?; \ No newline at end of file diff --git a/kitchen/src/web/storage/mod.rs b/kitchen/src/web/storage/mod.rs index e06e995..51dd5e5 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. use async_std::sync::Arc; +use std::collections::BTreeSet; use std::str::FromStr; use std::{collections::BTreeMap, path::Path}; @@ -28,7 +29,7 @@ use axum::{ }; use chrono::NaiveDate; use ciborium; -use recipes::RecipeEntry; +use recipes::{IngredientKey, RecipeEntry}; use secrecy::{ExposeSecret, Secret}; use serde::{Deserialize, Serialize}; use sqlx::{ @@ -119,6 +120,18 @@ pub trait APIStore { recipe_counts: &Vec<(String, i32)>, date: NaiveDate, ) -> Result<()>; + + async fn fetch_inventory_data + Send>( + &self, + user_id: S, + ) -> Result<(BTreeSet, BTreeMap)>; + + async fn save_inventory_data + Send>( + &self, + user_id: S, + filtered_ingredients: BTreeSet, + modified_amts: BTreeMap, + ) -> Result<()>; } #[async_trait] @@ -479,4 +492,106 @@ impl APIStore for SqliteStore { } Ok(Some(result)) } + + async fn fetch_inventory_data + Send>( + &self, + user_id: S, + ) -> Result<(BTreeSet, BTreeMap)> { + let user_id = user_id.as_ref(); + struct FilteredIngredientRow { + name: String, + form: String, + measure_type: String, + } + let filtered_ingredient_rows: Vec = sqlx::query_file_as!( + FilteredIngredientRow, + "src/web/storage/fetch_inventory_filtered_ingredients.sql", + user_id + ) + .fetch_all(self.pool.as_ref()) + .await?; + let mut filtered_ingredients = BTreeSet::new(); + for row in filtered_ingredient_rows { + filtered_ingredients.insert(IngredientKey::new( + row.name, + if row.form.is_empty() { + None + } else { + Some(row.form) + }, + row.measure_type, + )); + } + struct ModifiedAmtRow { + name: String, + form: String, + measure_type: String, + amt: String, + } + let modified_amt_rows = sqlx::query_file_as!( + ModifiedAmtRow, + "src/web/storage/fetch_inventory_modified_amts.sql", + user_id, + ) + .fetch_all(self.pool.as_ref()) + .await?; + let mut modified_amts = BTreeMap::new(); + for row in modified_amt_rows { + modified_amts.insert( + IngredientKey::new( + row.name, + if row.form.is_empty() { + None + } else { + Some(row.form) + }, + row.measure_type, + ), + row.amt, + ); + } + Ok((filtered_ingredients, modified_amts)) + } + + async fn save_inventory_data + Send>( + &self, + user_id: S, + filtered_ingredients: BTreeSet, + modified_amts: BTreeMap, + ) -> Result<()> { + let user_id = user_id.as_ref(); + let mut transaction = self.pool.as_ref().begin().await?; + for key in filtered_ingredients { + let name = key.name(); + let form = key.form(); + let measure_type = key.measure_type(); + sqlx::query_file!( + "src/web/storage/save_inventory_filtered_ingredients.sql", + user_id, + name, + form, + measure_type, + ) + .execute(&mut transaction) + .await?; + } + for (key, amt) in modified_amts { + let name = key.name(); + let form = key.form(); + let measure_type = key.measure_type(); + let amt = &amt; + sqlx::query_file!( + "src/web/storage/save_inventory_modified_amts.sql", + user_id, + name, + form, + measure_type, + amt, + ) + .execute(&mut transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } } diff --git a/kitchen/src/web/storage/save_inventory_filtered_ingredients.sql b/kitchen/src/web/storage/save_inventory_filtered_ingredients.sql new file mode 100644 index 0000000..fabce9f --- /dev/null +++ b/kitchen/src/web/storage/save_inventory_filtered_ingredients.sql @@ -0,0 +1,2 @@ +insert into filtered_ingredients(user_id, name, form, measure_type) + values (?, ?, ?, ?) \ No newline at end of file diff --git a/kitchen/src/web/storage/save_inventory_modified_amts.sql b/kitchen/src/web/storage/save_inventory_modified_amts.sql new file mode 100644 index 0000000..57a77b3 --- /dev/null +++ b/kitchen/src/web/storage/save_inventory_modified_amts.sql @@ -0,0 +1,2 @@ +insert into modified_amts(user_id, name, form, measure_type, amt) + values (?, ?, ?, ?, ?) \ No newline at end of file diff --git a/recipes/src/lib.rs b/recipes/src/lib.rs index 2259b23..286fd83 100644 --- a/recipes/src/lib.rs +++ b/recipes/src/lib.rs @@ -202,9 +202,27 @@ impl Step { /// Unique identifier for an Ingredient. Ingredients are identified by name, form, /// and measurement type. (Volume, Count, Weight) -#[derive(PartialEq, PartialOrd, Eq, Ord, Clone, Hash, Debug)] +#[derive(PartialEq, PartialOrd, Eq, Ord, Clone, Hash, Debug, Deserialize, Serialize)] pub struct IngredientKey(String, Option, String); +impl IngredientKey { + pub fn new(name: String, form: Option, measure_type: String) -> Self { + Self(name, form, measure_type) + } + + pub fn name(&self) -> &String { + &self.0 + } + + pub fn form(&self) -> String { + self.1.clone().unwrap_or_else(|| String::new()) + } + + pub fn measure_type(&self) -> &String { + &self.2 + } +} + /// Ingredient in a recipe. The `name` and `form` fields with the measurement type /// uniquely identify an ingredient. #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)] diff --git a/web/src/api.rs b/web/src/api.rs index 8ec42c1..ef1caf0 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -11,14 +11,14 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use reqwasm; use serde_json::{from_str, to_string}; use sycamore::prelude::*; use tracing::{debug, error, info, instrument, warn}; -use recipes::{parse, Recipe, RecipeEntry}; +use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; use wasm_bindgen::JsValue; use crate::{app_state, js_lib}; @@ -92,6 +92,16 @@ pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Res error!("{:?}", e); } } + info!("Synchronizing inventory data"); + match store.get_inventory_data().await { + Ok((filtered_ingredients, modified_amts)) => { + state.reset_modified_amts(modified_amts); + state.filtered_ingredients.set(filtered_ingredients); + } + Err(e) => { + error!("{:?}", e); + } + } Ok(()) } @@ -371,4 +381,41 @@ impl HttpStore { Ok(resp.json().await?) } } + + pub async fn get_inventory_data( + &self, + ) -> Result<(BTreeSet, BTreeMap), Error> { + let mut path = self.root.clone(); + path.push_str("/inventory"); + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back"); + let inventory = resp.json().await.map_err(|e| format!("{}", e))?; + Ok(inventory) + } + } + + pub async fn save_inventory_data( + &self, + filtered_ingredients: BTreeSet, + modified_amts: BTreeMap, + ) -> Result<(), Error> { + let mut path = self.root.clone(); + let serialized_inventory = to_string(&(filtered_ingredients, modified_amts)) + .expect("Unable to encode plan as json"); + path.push_str("/inventory"); + let resp = reqwasm::http::Request::post(&path) + .body(&serialized_inventory) + .header("content-type", "application/json") + .send() + .await?; + if resp.status() != 200 { + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(()) + } + } } diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 7329a27..73a6277 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -16,7 +16,7 @@ use std::collections::{BTreeMap, BTreeSet}; use sycamore::prelude::*; use tracing::{debug, instrument, warn}; -use recipes::{Ingredient, IngredientAccumulator, Recipe}; +use recipes::{Ingredient, IngredientAccumulator, IngredientKey, Recipe}; pub struct State { pub recipe_counts: RcSignal>>, @@ -24,6 +24,8 @@ pub struct State { pub staples: RcSignal>, pub recipes: RcSignal>, pub category_map: RcSignal>, + pub filtered_ingredients: RcSignal>, + pub modified_amts: RcSignal>>, } impl State { @@ -34,6 +36,8 @@ impl State { staples: create_rc_signal(None), recipes: create_rc_signal(BTreeMap::new()), category_map: create_rc_signal(BTreeMap::new()), + filtered_ingredients: create_rc_signal(BTreeSet::new()), + modified_amts: create_rc_signal(BTreeMap::new()), } } @@ -103,7 +107,7 @@ impl State { } pub fn reset_recipe_counts(&self) { - for (key, count) in self.recipe_counts.get_untracked().iter() { + for (_, count) in self.recipe_counts.get_untracked().iter() { count.set(0); } } @@ -119,4 +123,23 @@ impl State { self.recipe_counts.set(counts); self.recipe_counts.get_untracked().get(key).unwrap().clone() } + + pub fn get_current_modified_amts(&self) -> BTreeMap { + let mut modified_amts = BTreeMap::new(); + for (key, amt) in self.modified_amts.get_untracked().iter() { + modified_amts.insert(key.clone(), amt.get_untracked().as_ref().clone()); + } + modified_amts + } + + pub fn reset_modified_amts(&self, modified_amts: BTreeMap) { + let mut modified_amts_copy = self.modified_amts.get().as_ref().clone(); + for (key, amt) in modified_amts { + modified_amts_copy + .entry(key) + .and_modify(|amt_signal| amt_signal.set(amt.clone())) + .or_insert_with(|| create_rc_signal(amt)); + } + self.modified_amts.set(modified_amts_copy); + } } diff --git a/web/src/components/shopping_list.rs b/web/src/components/shopping_list.rs index d9aa0b2..8eef375 100644 --- a/web/src/components/shopping_list.rs +++ b/web/src/components/shopping_list.rs @@ -14,13 +14,13 @@ use std::collections::{BTreeMap, BTreeSet}; use recipes::{Ingredient, IngredientKey}; -use sycamore::prelude::*; +use sycamore::{futures::spawn_local_scoped, prelude::*}; use tracing::{debug, instrument}; fn make_ingredients_rows<'ctx, G: Html>( cx: Scope<'ctx>, ingredients: &'ctx ReadSignal))>>, - modified_amts: &'ctx Signal>>, + modified_amts: RcSignal>>, filtered_keys: RcSignal>, ) -> View { view!( @@ -111,7 +111,7 @@ fn make_extras_rows<'ctx, G: Html>( fn make_shopping_table<'ctx, G: Html>( cx: Scope<'ctx>, ingredients: &'ctx ReadSignal))>>, - modified_amts: &'ctx Signal>>, + modified_amts: RcSignal>>, extras: RcSignal, RcSignal))>>, filtered_keys: RcSignal>, ) -> View { @@ -137,10 +137,12 @@ fn make_shopping_table<'ctx, G: Html>( #[instrument] #[component] pub fn ShoppingList(cx: Scope) -> View { - let filtered_keys: RcSignal> = create_rc_signal(BTreeSet::new()); + let state = crate::app_state::State::get_from_context(cx); + // FIXME(jwall): We need to init this state for the page at some point. + let filtered_keys: RcSignal> = state.filtered_ingredients.clone(); let ingredients_map = create_rc_signal(BTreeMap::new()); - let modified_amts = create_signal(cx, BTreeMap::new()); let show_staples = create_signal(cx, true); + let save_click = create_signal(cx, ()); create_effect(cx, { let state = crate::app_state::State::get_from_context(cx); let ingredients_map = ingredients_map.clone(); @@ -174,7 +176,7 @@ pub fn ShoppingList(cx: Scope) -> View { table_view.set(make_shopping_table( cx, ingredients, - modified_amts.clone(), + state.modified_amts.clone(), state.extras.clone(), filtered_keys.clone(), )); @@ -183,6 +185,22 @@ pub fn ShoppingList(cx: Scope) -> View { } } }); + create_effect(cx, move || { + save_click.track(); + spawn_local_scoped(cx, { + let state = crate::app_state::State::get_from_context(cx); + let store = crate::api::HttpStore::get_from_context(cx); + async move { + store + .save_inventory_data( + state.filtered_ingredients.get_untracked().as_ref().clone(), + state.get_current_modified_amts(), + ) + .await + .expect("Unable to save inventory data"); + } + }) + }); let state = crate::app_state::State::get_from_context(cx); view! {cx, h1 { "Shopping List " } @@ -201,9 +219,12 @@ pub fn ShoppingList(cx: Scope) -> View { ingredients_map.set(state.get_shopping_list(*show_staples.get())); // clear the filter_signal filtered_keys.set(BTreeSet::new()); - modified_amts.set(BTreeMap::new()); + state.modified_amts.set(BTreeMap::new()); state.extras.set(Vec::new()); } }) + input(type="button", value="Save", class="no-print", on:click=|_| { + save_click.trigger_subscribers(); + }) } }