Add a way to delete recipe
authorGreg Burri <greg.burri@gmail.com>
Tue, 31 Dec 2024 10:26:51 +0000 (11:26 +0100)
committerGreg Burri <greg.burri@gmail.com>
Tue, 31 Dec 2024 10:26:51 +0000 (11:26 +0100)
backend/src/consts.rs
backend/src/data/db.rs
backend/src/data/db/recipe.rs
backend/src/data/model.rs
backend/src/main.rs
backend/src/services/recipe.rs
backend/src/services/ron.rs
backend/templates/recipe_edit.html
common/src/ron_api.rs
frontend/src/handles.rs

index bedffc8..4385309 100644 (file)
@@ -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")];
index 848c4d4..9797369 100644 (file)
@@ -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()
index e97c28b..6aeb4d6 100644 (file)
@@ -37,18 +37,20 @@ ORDER BY [title]
     }
 
     pub async fn can_edit_recipe(&self, user_id: i64, recipe_id: i64) -> Result<bool> {
-        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<bool> {
         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<bool> {
         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<u32>) -> 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<Vec<model::Group>> {
         let mut tx = self.tx().await?;
         let mut groups: Vec<model::Group> = sqlx::query_as(
index d837393..dd2bbb0 100644 (file)
@@ -28,7 +28,7 @@ pub struct Recipe {
     #[sqlx(try_from = "u32")]
     pub difficulty: Difficulty,
 
-    pub servings: u32,
+    pub servings: Option<u32>,
     pub is_published: bool,
     // pub tags: Vec<String>,
     // pub groups: Vec<Group>,
index 96f9066..5358bf6 100644 (file)
@@ -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))
index 806d7ba..aa69ce0 100644 (file)
@@ -33,25 +33,28 @@ pub async fn edit_recipe(
     Path(recipe_id): Path<i64>,
 ) -> Result<Response> {
     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())
index 8be7ab7..bbda7c2 100644 (file)
@@ -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<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::SetRecipeServings>,
+) -> Result<StatusCode> {
+    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<db::Connection>,
@@ -193,6 +208,17 @@ pub async fn set_is_published(
     Ok(StatusCode::OK)
 }
 
+#[debug_handler]
+pub async fn rm(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+    ExtractRon(ron): ExtractRon<common::ron_api::Remove>,
+) -> Result<impl IntoResponse> {
+    check_user_rights_recipe(&connection, &user, ron.recipe_id).await?;
+    connection.rm_recipe(ron.recipe_id).await?;
+    Ok(StatusCode::OK)
+}
+
 impl From<model::Group> for common::ron_api::Group {
     fn from(group: model::Group) -> Self {
         Self {
index 7ef799f..4b96b90 100644 (file)
     <textarea
         id="text-area-description">{{ recipe.description }}</textarea>
 
+    <label for="input-servings">Servings</label>
+    <input
+        id="input-servings"
+        type="number"
+        step="1" min="1" max="100"
+        value="
+            {% match recipe.servings %}
+            {% when Some with (s) %}
+                {{ s }}
+            {% when None %}
+
+            {% endmatch %}"/>
+
     <label for="input-estimated-time">Estimated time [min]</label>
     <input
         id="input-estimated-time"
@@ -30,7 +43,7 @@
             {% when Some with (t) %}
                 {{ t }}
             {% when None %}
-                0
+
             {% endmatch %}"/>
 
     <label for="select-difficulty">Difficulty</label>
@@ -61,6 +74,8 @@
     >
     <label for="input-is-published">Is published</label>
 
+    <input id="input-delete" type="button" value="Delete recipe" />
+
     <div id="groups-container">
 
     </div>
index cdaba4d..5efe510 100644 (file)
@@ -15,6 +15,12 @@ pub struct SetRecipeDescription {
     pub description: String,
 }
 
+#[derive(Serialize, Deserialize, Clone)]
+pub struct SetRecipeServings {
+    pub recipe_id: i64,
+    pub servings: Option<u32>,
+}
+
 #[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,
index 1512b0c..ac71751 100644 (file)
@@ -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::<u32>().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::<u32>().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::<Element>(&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::<HtmlInputElement>().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(())
-// }