diff --git a/Cargo.lock b/Cargo.lock index bdc092a..d6c90a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,7 +55,7 @@ checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61" [[package]] name = "api" -version = "0.1.0" +version = "0.1.1" dependencies = [ "axum", "chrono", diff --git a/api/Cargo.toml b/api/Cargo.toml index d5ef339..e4b48e3 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "api" -version = "0.1.0" +version = "0.1.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/api/src/lib.rs b/api/src/lib.rs index 7f0dd1c..1c04243 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -179,3 +179,11 @@ impl From for InventoryResponse { Response::Success(inventory_data) } } + +pub type CategoryMappingResponse = Response>; + +impl From> for CategoryMappingResponse { + fn from(mappings: Vec<(String, String)>) -> Self { + Response::Success(mappings) + } +} diff --git a/kitchen/migrations/20230105215101_category_maps.down.sql b/kitchen/migrations/20230105215101_category_maps.down.sql new file mode 100644 index 0000000..19dc19c --- /dev/null +++ b/kitchen/migrations/20230105215101_category_maps.down.sql @@ -0,0 +1,4 @@ +-- Add down migration script here + +drop index user_category_lookup; +drop table category_mappings; \ No newline at end of file diff --git a/kitchen/migrations/20230105215101_category_maps.up.sql b/kitchen/migrations/20230105215101_category_maps.up.sql new file mode 100644 index 0000000..53b43c3 --- /dev/null +++ b/kitchen/migrations/20230105215101_category_maps.up.sql @@ -0,0 +1,10 @@ +-- Add up migration script here + +create table category_mappings( + user_id TEXT NOT NULL, + ingredient_name TEXT NOT NULL, + category_name TEXT NOT NULL DEFAULT "Misc", + primary key(user_id, ingredient_name) +); + +create index user_category_lookup on category_mappings (user_id, category_name); \ No newline at end of file diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 44341ba..c481d79 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -112,6 +112,30 @@ }, "query": "select plan_date as \"plan_date: NaiveDate\", recipe_id, count\nfrom plan_recipes\nwhere\n user_id = ?\n and date(plan_date) > ?\norder by user_id, plan_date" }, + "37f382be1b53efd2f79a0d59ae6a8717f88a86908a7a4128d5ed7339147ca59d": { + "describe": { + "columns": [ + { + "name": "ingredient_name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "category_name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select ingredient_name, category_name from category_mappings where user_id = ?" + }, "3caefb86073c47b5dd5d05f639ddef2f7ed2d1fd80f224457d1ec34243cc56c7": { "describe": { "columns": [], @@ -318,6 +342,16 @@ }, "query": "select category_text from categories where user_id = ?" }, + "d73e4bfb1fbee6d2dd35fc787141a1c2909a77cf4b19950671f87e694289c242": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 3 + } + }, + "query": "insert into category_mappings\n (user_id, ingredient_name, category_name)\n values (?, ?, ?)" + }, "d84685a82585c5e4ae72c86ba1fe6e4a7241c4c3c9e948213e5849d956132bad": { "describe": { "columns": [], diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 31485e9..1ea349f 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -120,6 +120,47 @@ async fn api_recipes( result.into() } +#[instrument] +async fn api_category_mappings( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, +) -> api::CategoryMappingResponse { + use storage::UserIdFromSession::*; + match session { + NoUserId => api::Response::Unauthorized, + FoundUserId(user_id) => match app_store.get_category_mappings_for_user(&user_id.0).await { + Ok(Some(mappings)) => api::CategoryMappingResponse::from(mappings), + Ok(None) => api::CategoryMappingResponse::from(Vec::new()), + Err(e) => api::CategoryMappingResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + format!("{:?}", e), + ), + }, + } +} + +#[instrument] +async fn api_save_category_mappings( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Json(mappings): Json>, +) -> api::EmptyResponse { + use storage::UserIdFromSession::*; + match session { + NoUserId => api::Response::Unauthorized, + FoundUserId(user_id) => match app_store + .save_category_mappings_for_user(&user_id.0, &mappings) + .await + { + Ok(_) => api::EmptyResponse::success(()), + Err(e) => api::EmptyResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + format!("{:?}", e), + ), + }, + } +} + #[instrument] async fn api_categories( Extension(store): Extension>, @@ -369,7 +410,12 @@ fn mk_v2_routes() -> Router { "/inventory", get(api_inventory_v2).post(api_save_inventory_v2), ) + // TODO(jwall): This is now deprecated but will still work .route("/categories", get(api_categories).post(api_save_categories)) + .route( + "/category_map", + get(api_category_mappings).post(api_save_category_mappings), + ) // All the routes above require a UserId. .route("/auth", get(auth::handler).post(auth::handler)) .route("/account", get(api_user_account)) diff --git a/kitchen/src/web/storage/fetch_category_mappings_for_user.sql b/kitchen/src/web/storage/fetch_category_mappings_for_user.sql new file mode 100644 index 0000000..cc2de34 --- /dev/null +++ b/kitchen/src/web/storage/fetch_category_mappings_for_user.sql @@ -0,0 +1 @@ +select ingredient_name, category_name from category_mappings 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 70a2ee0..b1d0bce 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -90,6 +90,17 @@ fn check_pass(payload: &String, pass: &Secret) -> bool { pub trait APIStore { async fn get_categories_for_user(&self, user_id: &str) -> Result>; + async fn get_category_mappings_for_user( + &self, + user_id: &str, + ) -> Result>>; + + async fn save_category_mappings_for_user( + &self, + user_id: &str, + mappings: &Vec<(String, String)>, + ) -> Result<()>; + async fn get_recipes_for_user(&self, user_id: &str) -> Result>>; async fn store_recipes_for_user(&self, user_id: &str, recipes: &Vec) @@ -326,6 +337,50 @@ impl APIStore for SqliteStore { } } + async fn get_category_mappings_for_user( + &self, + user_id: &str, + ) -> Result>> { + struct Row { + ingredient_name: String, + category_name: String, + } + let rows: Vec = sqlx::query_file_as!( + Row, + "src/web/storage/fetch_category_mappings_for_user.sql", + user_id + ) + .fetch_all(self.pool.as_ref()) + .await?; + if rows.is_empty() { + Ok(None) + } else { + let mut mappings = Vec::new(); + for r in rows { + mappings.push((r.ingredient_name, r.category_name)); + } + Ok(Some(mappings)) + } + } + + async fn save_category_mappings_for_user( + &self, + user_id: &str, + mappings: &Vec<(String, String)>, + ) -> Result<()> { + for (name, category) in mappings.iter() { + sqlx::query_file!( + "src/web/storage/save_category_mappings_for_user.sql", + user_id, + name, + category, + ) + .execute(self.pool.as_ref()) + .await?; + } + Ok(()) + } + async fn get_recipe_entry_for_user + Send>( &self, user_id: S, diff --git a/kitchen/src/web/storage/save_category_mappings_for_user.sql b/kitchen/src/web/storage/save_category_mappings_for_user.sql new file mode 100644 index 0000000..870ba75 --- /dev/null +++ b/kitchen/src/web/storage/save_category_mappings_for_user.sql @@ -0,0 +1,3 @@ +insert into category_mappings + (user_id, ingredient_name, category_name) + values (?, ?, ?) \ No newline at end of file