From: Greg Burri Date: Tue, 31 Dec 2024 10:26:51 +0000 (+0100) Subject: Add a way to delete recipe X-Git-Url: https://git.euphorik.ch/?a=commitdiff_plain;h=31bc31035a104f0d728d1347870717f70d63fedd;p=recipes.git Add a way to delete recipe --- diff --git a/backend/src/consts.rs b/backend/src/consts.rs index bedffc8..4385309 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -19,6 +19,6 @@ pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60); // Common headers can be found in 'axum::http::header' (which is a re-export of the create 'http'). pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx). -pub const MAX_DB_CONNECTION: u32 = 1024; +pub const MAX_DB_CONNECTION: u32 = 1; // To avoid database lock. pub const LANGUAGES: [(&str, &str); 2] = [("Français", "fr"), ("English", "en")]; diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs index 848c4d4..9797369 100644 --- a/backend/src/data/db.rs +++ b/backend/src/data/db.rs @@ -4,10 +4,11 @@ use std::{ io::Read, path::Path, str::FromStr, + time::Duration, }; use sqlx::{ - sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, Pool, Sqlite, Transaction, }; use thiserror::Error; @@ -75,8 +76,9 @@ impl Connection { ))? .journal_mode(SqliteJournalMode::Wal) // TODO: use 'Wal2' when available. .create_if_missing(true) - .pragma("foreign_keys", "ON") - .pragma("synchronous", "NORMAL"); + .busy_timeout(Duration::from_secs(10)) + .foreign_keys(true) + .synchronous(SqliteSynchronous::Normal); Self::create_connection( SqlitePoolOptions::new() diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs index e97c28b..6aeb4d6 100644 --- a/backend/src/data/db/recipe.rs +++ b/backend/src/data/db/recipe.rs @@ -37,18 +37,20 @@ ORDER BY [title] } pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result { - sqlx::query_scalar(r#"SELECT COUNT(*) FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#) - .bind(recipe_id) - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(DBError::from) + sqlx::query_scalar( + r#"SELECT COUNT(*) = 1 FROM [Recipe] WHERE [id] = $1 AND [user_id] = $2"#, + ) + .bind(recipe_id) + .bind(user_id) + .fetch_one(&self.pool) + .await + .map_err(DBError::from) } pub async fn can_edit_recipe_group(&self, user_id: i64, group_id: i64) -> Result { sqlx::query_scalar( r#" -SELECT COUNT(*) +SELECT COUNT(*) = 1 FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] WHERE [Group].[id] = $1 AND [user_id] = $2 @@ -64,7 +66,7 @@ WHERE [Group].[id] = $1 AND [user_id] = $2 pub async fn can_edit_recipe_step(&self, user_id: i64, step_id: i64) -> Result { sqlx::query_scalar( r#" -SELECT COUNT(*) +SELECT COUNT(*) = 1 FROM [Recipe] INNER JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] INNER JOIN [Step] ON [Step].[group_id] = [Group].[id] @@ -171,6 +173,16 @@ WHERE [Recipe].[user_id] = $1 .map_err(DBError::from) } + pub async fn set_recipe_servings(&self, recipe_id: i64, servings: Option) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [servings] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(servings) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + pub async fn set_recipe_estimated_time( &self, recipe_id: i64, @@ -222,6 +234,15 @@ WHERE [Recipe].[user_id] = $1 .map_err(DBError::from) } + pub async fn rm_recipe(&self, recipe_id: i64) -> Result<()> { + sqlx::query("DELETE FROM [Recipe] WHERE [id] = $1") + .bind(recipe_id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + pub async fn get_groups(&self, recipe_id: i64) -> Result> { let mut tx = self.tx().await?; let mut groups: Vec = sqlx::query_as( diff --git a/backend/src/data/model.rs b/backend/src/data/model.rs index d837393..dd2bbb0 100644 --- a/backend/src/data/model.rs +++ b/backend/src/data/model.rs @@ -28,7 +28,7 @@ pub struct Recipe { #[sqlx(try_from = "u32")] pub difficulty: Difficulty, - pub servings: u32, + pub servings: Option, pub is_published: bool, // pub tags: Vec, // pub groups: Vec, diff --git a/backend/src/main.rs b/backend/src/main.rs index 96f9066..5358bf6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -91,6 +91,7 @@ async fn main() { "/recipe/set_description", put(services::ron::set_recipe_description), ) + .route("/recipe/set_servings", put(services::ron::set_servings)) .route( "/recipe/set_estimated_time", put(services::ron::set_estimated_time), @@ -101,6 +102,7 @@ async fn main() { "/recipe/set_is_published", put(services::ron::set_is_published), ) + .route("/recipe/remove", delete(services::ron::rm)) .route("/recipe/get_groups", get(services::ron::get_groups)) .route("/recipe/add_group", post(services::ron::add_group)) .route("/recipe/remove_group", delete(services::ron::rm_group)) diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs index 806d7ba..aa69ce0 100644 --- a/backend/src/services/recipe.rs +++ b/backend/src/services/recipe.rs @@ -33,25 +33,28 @@ pub async fn edit_recipe( Path(recipe_id): Path, ) -> Result { if let Some(user) = user { - let recipe = connection.get_recipe(recipe_id).await?.unwrap(); - if recipe.user_id == user.id { - let recipes = Recipes { - published: connection.get_all_published_recipe_titles().await?, - unpublished: connection - .get_all_unpublished_recipe_titles(user.id) - .await?, - current_id: Some(recipe_id), - }; + if let Some(recipe) = connection.get_recipe(recipe_id).await? { + if recipe.user_id == user.id { + let recipes = Recipes { + published: connection.get_all_published_recipe_titles().await?, + unpublished: connection + .get_all_unpublished_recipe_titles(user.id) + .await?, + current_id: Some(recipe_id), + }; - Ok(RecipeEditTemplate { - user: Some(user), - recipes, - recipe, - languages: consts::LANGUAGES, + Ok(RecipeEditTemplate { + user: Some(user), + recipes, + recipe, + languages: consts::LANGUAGES, + } + .into_response()) + } else { + Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response()) } - .into_response()) } else { - Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response()) + Ok(MessageTemplate::new("Recipe not found").into_response()) } } else { Ok(MessageTemplate::new("Not logged in").into_response()) diff --git a/backend/src/services/ron.rs b/backend/src/services/ron.rs index 8be7ab7..bbda7c2 100644 --- a/backend/src/services/ron.rs +++ b/backend/src/services/ron.rs @@ -14,6 +14,8 @@ use crate::{ ron_utils::{ron_error, ron_response}, }; +const NOT_AUTHORIZED_MESSAGE: &str = "Action not authorized"; + #[allow(dead_code)] #[debug_handler] pub async fn update_user( @@ -33,7 +35,7 @@ pub async fn update_user( } else { return Err(ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, - "Action not authorized", + NOT_AUTHORIZED_MESSAGE, ))); } Ok(StatusCode::OK) @@ -51,7 +53,7 @@ async fn check_user_rights_recipe( { Err(ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, - "Action not authorized", + NOT_AUTHORIZED_MESSAGE, ))) } else { Ok(()) @@ -70,7 +72,7 @@ async fn check_user_rights_recipe_group( { Err(ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, - "Action not authorized", + NOT_AUTHORIZED_MESSAGE, ))) } else { Ok(()) @@ -89,7 +91,7 @@ async fn check_user_rights_recipe_step( { Err(ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, - "Action not authorized", + NOT_AUTHORIZED_MESSAGE, ))) } else { Ok(()) @@ -108,7 +110,7 @@ async fn check_user_rights_recipe_ingredient( { Err(ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, - "Action not authorized", + NOT_AUTHORIZED_MESSAGE, ))) } else { Ok(()) @@ -141,6 +143,19 @@ pub async fn set_recipe_description( Ok(StatusCode::OK) } +#[debug_handler] +pub async fn set_servings( + State(connection): State, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + check_user_rights_recipe(&connection, &user, ron.recipe_id).await?; + connection + .set_recipe_servings(ron.recipe_id, ron.servings) + .await?; + Ok(StatusCode::OK) +} + #[debug_handler] pub async fn set_estimated_time( State(connection): State, @@ -193,6 +208,17 @@ pub async fn set_is_published( Ok(StatusCode::OK) } +#[debug_handler] +pub async fn rm( + State(connection): State, + Extension(user): Extension>, + ExtractRon(ron): ExtractRon, +) -> Result { + check_user_rights_recipe(&connection, &user, ron.recipe_id).await?; + connection.rm_recipe(ron.recipe_id).await?; + Ok(StatusCode::OK) +} + impl From for common::ron_api::Group { fn from(group: model::Group) -> Self { Self { diff --git a/backend/templates/recipe_edit.html b/backend/templates/recipe_edit.html index 7ef799f..4b96b90 100644 --- a/backend/templates/recipe_edit.html +++ b/backend/templates/recipe_edit.html @@ -20,6 +20,19 @@ + + + @@ -61,6 +74,8 @@ > + +
diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index cdaba4d..5efe510 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -15,6 +15,12 @@ pub struct SetRecipeDescription { pub description: String, } +#[derive(Serialize, Deserialize, Clone)] +pub struct SetRecipeServings { + pub recipe_id: i64, + pub servings: Option, +} + #[derive(Serialize, Deserialize, Clone)] pub struct SetRecipeEstimatedTime { pub recipe_id: i64, @@ -65,6 +71,11 @@ pub struct SetIsPublished { pub is_published: bool, } +#[derive(Serialize, Deserialize, Clone)] +pub struct Remove { + pub recipe_id: i64, +} + #[derive(Serialize, Deserialize, Clone)] pub struct AddRecipeGroup { pub recipe_id: i64, diff --git a/frontend/src/handles.rs b/frontend/src/handles.rs index 1512b0c..ac71751 100644 --- a/frontend/src/handles.rs +++ b/frontend/src/handles.rs @@ -54,116 +54,165 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> { { let description: HtmlTextAreaElement = by_id("text-area-description"); let mut current_description = description.value(); - let on_input_description_blur = - EventListener::new(&description.clone(), "blur", move |_event| { - if description.value() != current_description { - current_description = description.value(); - let body = ron_api::SetRecipeDescription { - recipe_id, - description: description.value(), - }; - spawn_local(async move { - let _ = request::put::<(), _>("recipe/set_description", body).await; - }); - } - }); - on_input_description_blur.forget(); + + EventListener::new(&description.clone(), "blur", move |_event| { + if description.value() != current_description { + current_description = description.value(); + let body = ron_api::SetRecipeDescription { + recipe_id, + description: description.value(), + }; + spawn_local(async move { + let _ = request::put::<(), _>("recipe/set_description", body).await; + }); + } + }) + .forget(); + } + + // Servings. + { + let servings: HtmlInputElement = by_id("input-servings"); + let mut current_servings = servings.value_as_number(); + EventListener::new(&servings.clone(), "input", move |_event| { + let n = servings.value_as_number(); + if n.is_nan() { + servings.set_value(""); + } + if n != current_servings { + let servings = if n.is_nan() { + None + } else { + // TODO: Find a better way to validate integer numbers. + let n = n as u32; + servings.set_value_as_number(n as f64); + Some(n) + }; + current_servings = n; + let body = ron_api::SetRecipeServings { + recipe_id, + servings, + }; + spawn_local(async move { + let _ = request::put::<(), _>("recipe/set_servings", body).await; + }); + } + }) + .forget(); } // Estimated time. { let estimated_time: HtmlInputElement = by_id("input-estimated-time"); let mut current_time = estimated_time.value_as_number(); - let on_input_estimated_time_blur = - EventListener::new(&estimated_time.clone(), "blur", move |_event| { - let n = estimated_time.value_as_number(); - if n.is_nan() { - estimated_time.set_value(""); - } - if n != current_time { - let time = if n.is_nan() { - None - } else { - // TODO: Find a better way to validate integer numbers. - let n = n as u32; - estimated_time.set_value_as_number(n as f64); - Some(n) - }; - current_time = n; - let body = ron_api::SetRecipeEstimatedTime { - recipe_id, - estimated_time: time, - }; - spawn_local(async move { - let _ = request::put::<(), _>("recipe/set_estimated_time", body).await; - }); - } - }); - on_input_estimated_time_blur.forget(); + + EventListener::new(&estimated_time.clone(), "input", move |_event| { + let n = estimated_time.value_as_number(); + if n.is_nan() { + estimated_time.set_value(""); + } + if n != current_time { + let time = if n.is_nan() { + None + } else { + // TODO: Find a better way to validate integer numbers. + let n = n as u32; + estimated_time.set_value_as_number(n as f64); + Some(n) + }; + current_time = n; + let body = ron_api::SetRecipeEstimatedTime { + recipe_id, + estimated_time: time, + }; + spawn_local(async move { + let _ = request::put::<(), _>("recipe/set_estimated_time", body).await; + }); + } + }) + .forget(); } // Difficulty. { let difficulty: HtmlSelectElement = by_id("select-difficulty"); let mut current_difficulty = difficulty.value(); - let on_select_difficulty_blur = - EventListener::new(&difficulty.clone(), "blur", move |_event| { - if difficulty.value() != current_difficulty { - current_difficulty = difficulty.value(); - - let body = ron_api::SetRecipeDifficulty { - recipe_id, - difficulty: ron_api::Difficulty::try_from( - current_difficulty.parse::().unwrap(), - ) - .unwrap(), - }; - spawn_local(async move { - let _ = request::put::<(), _>("recipe/set_difficulty", body).await; - }); - } - }); - on_select_difficulty_blur.forget(); + + EventListener::new(&difficulty.clone(), "blur", move |_event| { + if difficulty.value() != current_difficulty { + current_difficulty = difficulty.value(); + + let body = ron_api::SetRecipeDifficulty { + recipe_id, + difficulty: ron_api::Difficulty::try_from( + current_difficulty.parse::().unwrap(), + ) + .unwrap(), + }; + spawn_local(async move { + let _ = request::put::<(), _>("recipe/set_difficulty", body).await; + }); + } + }) + .forget(); } // Language. { let language: HtmlSelectElement = by_id("select-language"); let mut current_language = language.value(); - let on_select_language_blur = - EventListener::new(&language.clone(), "blur", move |_event| { - if language.value() != current_language { - current_language = language.value(); - - let body = ron_api::SetRecipeLanguage { - recipe_id, - lang: language.value(), - }; - spawn_local(async move { - let _ = request::put::<(), _>("recipe/set_language", body).await; - }); - } - }); - on_select_language_blur.forget(); - } + EventListener::new(&language.clone(), "blur", move |_event| { + if language.value() != current_language { + current_language = language.value(); - // Is published. - { - let is_published: HtmlInputElement = by_id("input-is-published"); - let on_input_is_published_blur = - EventListener::new(&is_published.clone(), "input", move |_event| { - let body = ron_api::SetIsPublished { + let body = ron_api::SetRecipeLanguage { recipe_id, - is_published: is_published.checked(), + lang: language.value(), }; spawn_local(async move { - let _ = request::put::<(), _>("recipe/set_is_published", body).await; - reload_recipes_list(recipe_id).await; + let _ = request::put::<(), _>("recipe/set_language", body).await; }); + } + }) + .forget(); + } + + // Is published. + { + let is_published: HtmlInputElement = by_id("input-is-published"); + EventListener::new(&is_published.clone(), "input", move |_event| { + let body = ron_api::SetIsPublished { + recipe_id, + is_published: is_published.checked(), + }; + spawn_local(async move { + let _ = request::put::<(), _>("recipe/set_is_published", body).await; + reload_recipes_list(recipe_id).await; }); - on_input_is_published_blur.forget(); + }) + .forget(); } + // Delete recipe button. + let delete_button: HtmlInputElement = by_id("input-delete"); + EventListener::new(&delete_button, "click", move |_event| { + let title: HtmlInputElement = by_id("input-title"); + spawn_local(async move { + if modal_dialog::show(&format!( + "Are you sure to delete the recipe '{}'", + title.value() + )) + .await + { + let body = ron_api::Remove { recipe_id }; + let _ = request::delete::<(), _>("recipe/remove", body).await; + + // by_id::(&format!("group-{}", group_id)).remove(); + } + }); + }) + .forget(); + fn create_group_element(group: &ron_api::Group) -> Element { let group_id = group.id; let group_element: Element = select_and_clone("#hidden-templates .group"); @@ -374,7 +423,7 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> { .map_or("".to_string(), |q| q.to_string()), ); let mut current_quantity = quantity.value_as_number(); - EventListener::new(&quantity.clone(), "blur", move |_event| { + EventListener::new(&quantity.clone(), "input", move |_event| { let n = quantity.value_as_number(); if n.is_nan() { quantity.set_value(""); @@ -479,60 +528,3 @@ pub fn recipe_edit(recipe_id: i64) -> Result<(), JsValue> { Ok(()) } - -// pub fn user_edit(doc: Document) -> Result<(), JsValue> { -// log!("user_edit"); - -// let button = doc -// .query_selector("#user-edit input[type='button']")? -// .unwrap(); - -// let on_click_submit = EventListener::new(&button, "click", move |_event| { -// log!("Click!"); - -// let input_name = doc.get_element_by_id("input-name").unwrap(); -// let name = input_name.dyn_ref::().unwrap().value(); - -// let update_data = common::ron_api::UpdateProfile { -// name: Some(name), -// email: None, -// password: None, -// }; - -// let body = common::ron_api::to_string(update_data); - -// let doc = doc.clone(); -// spawn_local(async move { -// match Request::put("/ron-api/user/update") -// .header("Content-Type", "application/ron") -// .body(body) -// .unwrap() -// .send() -// .await -// { -// Ok(resp) => { -// log!("Status code: {}", resp.status()); -// if resp.status() == 200 { -// toast::show(Level::Info, "Profile saved"); -// } else { -// toast::show( -// Level::Error, -// &format!( -// "Status code: {} {}", -// resp.status(), -// resp.text().await.unwrap() -// ), -// ); -// } -// } -// Err(error) => { -// toast::show(Level::Info, &format!("Internal server error: {}", error)); -// } -// } -// }); -// }); - -// on_click_submit.forget(); - -// Ok(()) -// }