Split db::Connection implementation in submodules (db::user and db::recipe).
authorGreg Burri <greg.burri@gmail.com>
Wed, 18 Dec 2024 22:10:19 +0000 (23:10 +0100)
committerGreg Burri <greg.burri@gmail.com>
Wed, 18 Dec 2024 22:10:19 +0000 (23:10 +0100)
18 files changed:
Cargo.lock
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/data/db.rs
backend/src/data/db/recipe.rs [new file with mode: 0644]
backend/src/data/db/user.rs [new file with mode: 0644]
backend/src/data/mod.rs
backend/src/data/model.rs [new file with mode: 0644]
backend/src/data/utils.rs
backend/src/html_templates.rs
backend/src/main.rs
backend/src/model.rs [deleted file]
backend/src/services.rs
backend/templates/base_with_list.html
backend/templates/recipe_edit.html
backend/templates/recipe_view.html [new file with mode: 0644]
backend/templates/sign_up_form.html
backend/templates/view_recipe.html [deleted file]

index 5ab7bfd..8598d80 100644 (file)
@@ -2006,7 +2006,7 @@ dependencies = [
  "ron",
  "serde",
  "sqlx",
- "thiserror 2.0.7",
+ "thiserror 2.0.8",
  "tokio",
  "tower",
  "tower-http",
@@ -2672,11 +2672,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
-version = "2.0.7"
+version = "2.0.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767"
+checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a"
 dependencies = [
- "thiserror-impl 2.0.7",
+ "thiserror-impl 2.0.8",
 ]
 
 [[package]]
@@ -2692,9 +2692,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.7"
+version = "2.0.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36"
+checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943"
 dependencies = [
  "proc-macro2",
  "quote",
index 72ccb3d..af9cc03 100644 (file)
@@ -1,4 +1,4 @@
-INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
 VALUES (
     1,
     'paul@atreides.com',
@@ -8,7 +8,7 @@ VALUES (
     NULL
 );
 
-INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
 VALUES (
     2,
     'alia@atreides.com',
index 915e321..2462b2b 100644 (file)
@@ -47,7 +47,9 @@ CREATE TABLE [Recipe] (
     [id] INTEGER PRIMARY KEY,
     [user_id] INTEGER, -- Can be null if a user is deleted.
     [title] TEXT NOT NULL,
-    [estimate_time] INTEGER,
+     -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
+    [lang] TEXT NOT NULL DEFAULT 'en',
+    [estimate_time] INTEGER, -- in [s].
     [description] TEXT NOT NULL DEFAULT '',
     [difficulty] INTEGER NOT NULL DEFAULT 0,
     [servings] INTEGER DEFAULT 4,
@@ -61,7 +63,7 @@ CREATE TABLE [Image] (
     [recipe_id] INTEGER NOT NULL,
     [name] TEXT NOT NULL DEFAULT '',
     [description] TEXT NOT NULL DEFAULT '',
-    [image] BLOB,
+    [image] BLOB NOT NULL,
 
     FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
 ) STRICT;
@@ -80,26 +82,30 @@ CREATE TABLE [RecipeTag] (
 
 CREATE TABLE [Tag] (
     [id] INTEGER PRIMARY KEY,
-    [name] TEXT NOT NULL
+    [name] TEXT NOT NULL,
+     -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
+    [lang] TEXT NOT NULL DEFAULT 'en'
 ) STRICT;
 
-CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]);
+CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]);
 
 CREATE TABLE [Ingredient] (
     [id] INTEGER PRIMARY KEY,
     [name] TEXT NOT NULL,
+    [comment] TEXT NOT NULL DEFAULT '',
     [quantity_value] REAL,
     [quantity_unit] TEXT NOT NULL DEFAULT '',
-    [input_group_id] INTEGER NOT NULL,
+    [input_step_id] INTEGER NOT NULL,
 
-    FOREIGN KEY([input_group_id]) REFERENCES [Group]([id]) ON DELETE CASCADE
+    FOREIGN KEY([input_step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
 ) STRICT;
 
 CREATE TABLE [Group] (
     [id] INTEGER PRIMARY KEY,
     [order] INTEGER NOT NULL DEFAULT 0,
-    [recipe_id] INTEGER,
+    [recipe_id] INTEGER NOT NULL,
     [name] TEXT NOT NULL DEFAULT '',
+    [comment] TEXT NOT NULL DEFAULT '',
 
     FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
 ) STRICT;
@@ -117,14 +123,14 @@ CREATE TABLE [Step] (
 
 CREATE INDEX [Step_order_index] ON [Group]([order]);
 
-CREATE TABLE [IntermediateSubstance] (
-    [id] INTEGER PRIMARY KEY,
-    [name] TEXT NOT NULL DEFAULT '',
-    [quantity_value] REAL,
-    [quantity_unit] TEXT NOT NULL DEFAULT '',
-    [output_group_id] INTEGER NOT NULL,
-    [input_group_id] INTEGER NOT NULL,
-
-    FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
-    FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
-) STRICT;
+-- CREATE TABLE [IntermediateSubstance] (
+--     [id] INTEGER PRIMARY KEY,
+--     [name] TEXT NOT NULL DEFAULT '',
+--     [quantity_value] REAL,
+--     [quantity_unit] TEXT NOT NULL DEFAULT '',
+--     [output_group_id] INTEGER NOT NULL,
+--     [input_group_id] INTEGER NOT NULL,
+
+--     FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
+--     FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
+-- ) STRICT;
index b1b7829..3124db9 100644 (file)
@@ -6,8 +6,6 @@ use std::{
     str::FromStr,
 };
 
-use chrono::{prelude::*, Duration};
-use rand::distributions::{Alphanumeric, DistString};
 use sqlx::{
     sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
     Pool, Sqlite, Transaction,
@@ -15,11 +13,11 @@ use sqlx::{
 use thiserror::Error;
 use tracing::{event, Level};
 
-use crate::{
-    consts,
-    hash::{hash, verify_password},
-    model,
-};
+use super::model;
+use crate::consts;
+
+pub mod recipe;
+pub mod user;
 
 const CURRENT_DB_VERSION: u32 = 1;
 
@@ -46,53 +44,6 @@ impl DBError {
 
 type Result<T> = std::result::Result<T, DBError>;
 
-#[derive(Debug)]
-pub enum SignUpResult {
-    UserAlreadyExists,
-    UserCreatedWaitingForValidation(String), // Validation token.
-}
-
-#[derive(Debug)]
-pub enum UpdateUserResult {
-    EmailAlreadyTaken,
-    UserUpdatedWaitingForRevalidation(String), // Validation token.
-    Ok,
-}
-
-#[derive(Debug)]
-pub enum ValidationResult {
-    UnknownUser,
-    ValidationExpired,
-    Ok(String, i64), // Returns token and user id.
-}
-
-#[derive(Debug)]
-pub enum SignInResult {
-    UserNotFound,
-    WrongPassword,
-    AccountNotValidated,
-    Ok(String, i64), // Returns token and user id.
-}
-
-#[derive(Debug)]
-pub enum AuthenticationResult {
-    NotValidToken,
-    Ok(i64), // Returns user id.
-}
-
-#[derive(Debug)]
-pub enum GetTokenResetPasswordResult {
-    PasswordAlreadyReset,
-    EmailUnknown,
-    Ok(String),
-}
-
-#[derive(Debug)]
-pub enum ResetPasswordResult {
-    ResetTokenExpired,
-    Ok,
-}
-
 #[derive(Clone)]
 pub struct Connection {
     pool: Pool<Sqlite>,
@@ -220,472 +171,6 @@ WHERE [type] = 'table' AND [name] = 'Version'
         }
     }
 
-    pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
-        sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")
-            .fetch_all(&self.pool)
-            .await
-            .map_err(DBError::from)
-    }
-
-    pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
-        sqlx::query_as(
-            r#"
-SELECT [id], [user_id], [title], [description]
-FROM [Recipe] WHERE [id] = $1
-            "#,
-        )
-        .bind(id)
-        .fetch_optional(&self.pool)
-        .await
-        .map_err(DBError::from)
-    }
-
-    #[cfg(test)]
-    pub async fn get_user_login_info(&self, token: &str) -> Result<model::UserLoginInfo> {
-        sqlx::query_as(
-            r#"
-SELECT [last_login_datetime], [ip], [user_agent]
-FROM [UserLoginToken] WHERE [token] = $1
-            "#,
-        )
-        .bind(token)
-        .fetch_one(&self.pool)
-        .await
-        .map_err(DBError::from)
-    }
-
-    pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
-        sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
-            .bind(user_id)
-            .fetch_optional(&self.pool)
-            .await
-            .map_err(DBError::from)
-    }
-
-    /// If a new email is given and it doesn't match the current one then it has to be
-    /// Revalidated.
-    pub async fn update_user(
-        &self,
-        user_id: i64,
-        new_email: Option<&str>,
-        new_name: Option<&str>,
-        new_password: Option<&str>,
-    ) -> Result<UpdateUserResult> {
-        let mut tx = self.tx().await?;
-        let hashed_new_password = new_password.map(|p| hash(p).unwrap());
-
-        let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>(
-            "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1",
-        )
-        .bind(user_id)
-        .fetch_one(&mut *tx)
-        .await?;
-
-        let email_changed = new_email.is_some_and(|new_email| new_email != email);
-
-        // Check if email not already taken.
-        let validation_token = if email_changed {
-            if sqlx::query_scalar::<_, i64>(
-                r#"
-SELECT COUNT(*)
-FROM [User]
-WHERE [email] = $1
-                "#,
-            )
-            .bind(new_email.unwrap())
-            .fetch_one(&mut *tx)
-            .await?
-                > 0
-            {
-                return Ok(UpdateUserResult::EmailAlreadyTaken);
-            }
-
-            let token = Some(generate_token());
-            sqlx::query(
-                r#"
-UPDATE [User]
-SET [validation_token] = $2, [validation_token_datetime] = $3
-WHERE [id] = $1
-            "#,
-            )
-            .bind(user_id)
-            .bind(&token)
-            .bind(Utc::now())
-            .execute(&mut *tx)
-            .await?;
-            token
-        } else {
-            None
-        };
-
-        sqlx::query(
-            r#"
-UPDATE [User]
-SET [email] = $2, [name] = $3, [password] = $4
-WHERE [id] = $1
-            "#,
-        )
-        .bind(user_id)
-        .bind(new_email.unwrap_or(&email))
-        .bind(new_name.unwrap_or(&name))
-        .bind(hashed_new_password.unwrap_or(hashed_password))
-        .execute(&mut *tx)
-        .await?;
-
-        tx.commit().await?;
-
-        Ok(if let Some(validation_token) = validation_token {
-            UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token)
-        } else {
-            UpdateUserResult::Ok
-        })
-    }
-
-    pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
-        self.sign_up_with_given_time(email, password, Utc::now())
-            .await
-    }
-
-    async fn sign_up_with_given_time(
-        &self,
-        email: &str,
-        password: &str,
-        datetime: DateTime<Utc>,
-    ) -> Result<SignUpResult> {
-        let mut tx = self.tx().await?;
-
-        let token = match sqlx::query_as::<_, (i64, Option<String>)>(
-            r#"
-SELECT [id], [validation_token]
-FROM [User] WHERE [email] = $1
-            "#,
-        )
-        .bind(email)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            Some((id, validation_token)) => {
-                if validation_token.is_none() {
-                    return Ok(SignUpResult::UserAlreadyExists);
-                }
-                let token = generate_token();
-                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
-                sqlx::query(
-                    r#"
-UPDATE [User]
-SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4
-WHERE [id] = $1
-                    "#,
-                )
-                .bind(id)
-                .bind(&token)
-                .bind(datetime)
-                .bind(hashed_password)
-                .execute(&mut *tx)
-                .await?;
-                token
-            }
-            None => {
-                let token = generate_token();
-                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
-                sqlx::query(
-                    r#"
-INSERT INTO [User]
-([email], [validation_token], [validation_token_datetime], [password])
-VALUES ($1, $2, $3, $4)
-                    "#,
-                )
-                .bind(email)
-                .bind(&token)
-                .bind(datetime)
-                .bind(hashed_password)
-                .execute(&mut *tx)
-                .await?;
-                token
-            }
-        };
-
-        tx.commit().await?;
-
-        Ok(SignUpResult::UserCreatedWaitingForValidation(token))
-    }
-
-    pub async fn validation(
-        &self,
-        token: &str,
-        validation_time: Duration,
-        ip: &str,
-        user_agent: &str,
-    ) -> Result<ValidationResult> {
-        let mut tx = self.tx().await?;
-
-        // There is no index on [validation_token]. Is it useful?
-        let user_id = match sqlx::query_as::<_, (i64, DateTime<Utc>)>(
-            "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1",
-        )
-        .bind(token)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            Some((id, validation_token_datetime)) => {
-                if Utc::now() - validation_token_datetime > validation_time {
-                    return Ok(ValidationResult::ValidationExpired);
-                }
-                sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1")
-                    .bind(id)
-                    .execute(&mut *tx)
-                    .await?;
-                id
-            }
-            None => return Ok(ValidationResult::UnknownUser),
-        };
-
-        let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?;
-
-        tx.commit().await?;
-        Ok(ValidationResult::Ok(token, user_id))
-    }
-
-    pub async fn sign_in(
-        &self,
-        email: &str,
-        password: &str,
-        ip: &str,
-        user_agent: &str,
-    ) -> Result<SignInResult> {
-        let mut tx = self.tx().await?;
-        match sqlx::query_as::<_, (i64, String, Option<String>)>(
-            "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1",
-        )
-        .bind(email)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            Some((id, stored_password, validation_token)) => {
-                if validation_token.is_some() {
-                    Ok(SignInResult::AccountNotValidated)
-                } else if verify_password(password, &stored_password)
-                    .map_err(DBError::from_dyn_error)?
-                {
-                    let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?;
-                    tx.commit().await?;
-                    Ok(SignInResult::Ok(token, id))
-                } else {
-                    Ok(SignInResult::WrongPassword)
-                }
-            }
-            None => Ok(SignInResult::UserNotFound),
-        }
-    }
-
-    pub async fn authentication(
-        &self,
-        token: &str,
-        ip: &str,
-        user_agent: &str,
-    ) -> Result<AuthenticationResult> {
-        let mut tx = self.tx().await?;
-        match sqlx::query_as::<_, (i64, i64)>(
-            "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1",
-        )
-        .bind(token)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            Some((login_id, user_id)) => {
-                sqlx::query(
-                    r#"
-UPDATE [UserLoginToken]
-SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4
-WHERE [id] = $1
-                    "#,
-                )
-                .bind(login_id)
-                .bind(Utc::now())
-                .bind(ip)
-                .bind(user_agent)
-                .execute(&mut *tx)
-                .await?;
-                tx.commit().await?;
-                Ok(AuthenticationResult::Ok(user_id))
-            }
-            None => Ok(AuthenticationResult::NotValidToken),
-        }
-    }
-
-    pub async fn sign_out(&self, token: &str) -> Result<()> {
-        let mut tx = self.tx().await?;
-        match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
-            .bind(token)
-            .fetch_optional(&mut *tx)
-            .await?
-        {
-            Some(login_id) => {
-                sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
-                    .bind(login_id)
-                    .execute(&mut *tx)
-                    .await?;
-                tx.commit().await?;
-            }
-            None => (),
-        }
-        Ok(())
-    }
-
-    pub async fn get_token_reset_password(
-        &self,
-        email: &str,
-        validation_time: Duration,
-    ) -> Result<GetTokenResetPasswordResult> {
-        let mut tx = self.tx().await?;
-
-        if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
-            r#"
-SELECT [password_reset_datetime]
-FROM [User]
-WHERE [email] = $1
-            "#,
-        )
-        .bind(email)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            if let Some(db_datetime) = db_datetime_nullable {
-                if Utc::now() - db_datetime <= validation_time {
-                    return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset);
-                }
-            }
-        } else {
-            return Ok(GetTokenResetPasswordResult::EmailUnknown);
-        }
-
-        let token = generate_token();
-
-        sqlx::query(
-            r#"
-UPDATE [User]
-SET [password_reset_token] = $2, [password_reset_datetime] = $3
-WHERE [email] = $1
-            "#,
-        )
-        .bind(email)
-        .bind(&token)
-        .bind(Utc::now())
-        .execute(&mut *tx)
-        .await?;
-
-        tx.commit().await?;
-
-        Ok(GetTokenResetPasswordResult::Ok(token))
-    }
-
-    pub async fn reset_password(
-        &self,
-        new_password: &str,
-        token: &str,
-        validation_time: Duration,
-    ) -> Result<ResetPasswordResult> {
-        let mut tx = self.tx().await?;
-        // There is no index on [password_reset_token]. Is it useful?
-        if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
-            r#"
-SELECT [id], [password_reset_datetime]
-FROM [User]
-WHERE [password_reset_token] = $1
-            "#,
-        )
-        .bind(token)
-        .fetch_one(&mut *tx)
-        .await?
-        {
-            if Utc::now() - db_datetime > validation_time {
-                return Ok(ResetPasswordResult::ResetTokenExpired);
-            }
-
-            // Remove all login tokens (for security reasons).
-            sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1")
-                .bind(user_id)
-                .execute(&mut *tx)
-                .await?;
-
-            let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
-
-            sqlx::query(
-                r#"
-UPDATE [User]
-SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL
-WHERE [id] = $1
-                "#,
-            )
-            .bind(user_id)
-            .bind(hashed_new_password)
-            .execute(&mut *tx)
-            .await?;
-
-            tx.commit().await?;
-
-            Ok(ResetPasswordResult::Ok)
-        } else {
-            Err(DBError::Other(
-                "Can't reset password: stored token or datetime not set (NULL)".to_string(),
-            ))
-        }
-    }
-
-    pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
-        let mut tx = self.tx().await?;
-
-        match sqlx::query_scalar::<_, i64>(
-            r#"
-SELECT [Recipe].[id] FROM [Recipe]
-LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id]
-LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
-WHERE [Recipe].[user_id] = $1
-    AND [Recipe].[title] = ''
-    AND [Recipe].[estimate_time] IS NULL
-    AND [Recipe].[description] = ''
-    AND [Image].[id] IS NULL
-    AND [Group].[id] IS NULL
-            "#,
-        )
-        .bind(user_id)
-        .fetch_optional(&mut *tx)
-        .await?
-        {
-            Some(recipe_id) => Ok(recipe_id),
-            None => {
-                let db_result =
-                    sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')")
-                        .bind(user_id)
-                        .execute(&mut *tx)
-                        .await?;
-
-                tx.commit().await?;
-                Ok(db_result.last_insert_rowid())
-            }
-        }
-    }
-
-    pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> {
-        sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1")
-            .bind(recipe_id)
-            .bind(title)
-            .execute(&self.pool)
-            .await
-            .map(|_| ())
-            .map_err(DBError::from)
-    }
-
-    pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> {
-        sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1")
-            .bind(recipe_id)
-            .bind(description)
-            .execute(&self.pool)
-            .await
-            .map(|_| ())
-            .map_err(DBError::from)
-    }
-
     /// Execute a given SQL file.
     pub async fn execute_file<P: AsRef<Path> + fmt::Display>(&self, file: P) -> Result<()> {
         let sql = load_sql_file(file)?;
@@ -706,31 +191,6 @@ WHERE [Recipe].[user_id] = $1
             .map(|db_result| db_result.rows_affected())
             .map_err(DBError::from)
     }
-
-    // Return the token.
-    async fn create_login_token(
-        tx: &mut sqlx::Transaction<'_, Sqlite>,
-        user_id: i64,
-        ip: &str,
-        user_agent: &str,
-    ) -> Result<String> {
-        let token = generate_token();
-        sqlx::query(
-            r#"
-INSERT INTO [UserLoginToken]
-([user_id], [last_login_datetime], [token], [ip], [user_agent])
-VALUES ($1, $2, $3, $4, $5)
-            "#,
-        )
-        .bind(user_id)
-        .bind(Utc::now())
-        .bind(&token)
-        .bind(ip)
-        .bind(user_agent)
-        .execute(&mut **tx)
-        .await?;
-        Ok(token)
-    }
 }
 
 fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
@@ -752,515 +212,8 @@ fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
     Ok(sql)
 }
 
-fn generate_token() -> String {
-    Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[tokio::test]
-    async fn sign_up() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        match connection.sign_up("paul@atreides.com", "12345").await? {
-            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_to_an_already_existing_user() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        connection.execute_sql(
-            sqlx::query(
-            r#"
-INSERT INTO
-    [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-    VALUES (
-        1,
-        'paul@atreides.com',
-        'paul',
-        '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
-        0,
-        NULL
-    );
-            "#)).await?;
-        match connection.sign_up("paul@atreides.com", "12345").await? {
-            SignUpResult::UserAlreadyExists => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_and_sign_in_without_validation() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-        let password = "12345";
-
-        match connection.sign_up(email, password).await? {
-            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-
-        match connection
-            .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
-            .await?
-        {
-            SignInResult::AccountNotValidated => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        let token = generate_token();
-        connection.execute_sql(
-            sqlx::query(
-            r#"
-INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES (
-    1,
-    'paul@atreides.com',
-    'paul',
-    '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
-    0,
-    $1
-)
-            "#
-        ).bind(token)).await?;
-        match connection.sign_up("paul@atreides.com", "12345").await? {
-            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_at_time() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-        match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla/5.0",
-            )
-            .await?
-        {
-            ValidationResult::Ok(_, _) => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_too_late() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        let validation_token = match connection
-            .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1))
-            .await?
-        {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-        match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla/5.0",
-            )
-            .await?
-        {
-            ValidationResult::ValidationExpired => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_with_bad_token() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-        let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-        let random_token = generate_token();
-        match connection
-            .validation(
-                &random_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla/5.0",
-            )
-            .await?
-        {
-            ValidationResult::UnknownUser => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_then_sign_in() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-        let password = "12345";
-
-        // Sign up.
-        let validation_token = match connection.sign_up(email, password).await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-
-        // Validation.
-        match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla/5.0",
-            )
-            .await?
-        {
-            ValidationResult::Ok(_, _) => (),
-            other => panic!("{:?}", other),
-        };
-
-        // Sign in.
-        match connection
-            .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
-            .await?
-        {
-            SignInResult::Ok(_, _) => (), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_then_authentication() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-        let password = "12345";
-
-        // Sign up.
-        let validation_token = match connection.sign_up(email, password).await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-
-        // Validation.
-        let (authentication_token, _user_id) = match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla",
-            )
-            .await?
-        {
-            ValidationResult::Ok(token, user_id) => (token, user_id),
-            other => panic!("{:?}", other),
-        };
-
-        // Check user login information.
-        let user_login_info_1 = connection
-            .get_user_login_info(&authentication_token)
-            .await?;
-        assert_eq!(user_login_info_1.ip, "127.0.0.1");
-        assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
-        // Authentication.
-        let _user_id = match connection
-            .authentication(&authentication_token, "192.168.1.1", "Chrome")
-            .await?
-        {
-            AuthenticationResult::Ok(user_id) => user_id, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-
-        // Check user login information.
-        let user_login_info_2 = connection
-            .get_user_login_info(&authentication_token)
-            .await?;
-
-        assert_eq!(user_login_info_2.ip, "192.168.1.1");
-        assert_eq!(user_login_info_2.user_agent, "Chrome");
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-        let password = "12345";
-
-        // Sign up.
-        let validation_token = match connection.sign_up(email, password).await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-
-        // Validation.
-        let (authentication_token_1, user_id_1) = match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla",
-            )
-            .await?
-        {
-            ValidationResult::Ok(token, user_id) => (token, user_id),
-            other => panic!("{:?}", other),
-        };
-
-        // Check user login information.
-        let user_login_info_1 = connection
-            .get_user_login_info(&authentication_token_1)
-            .await?;
-        assert_eq!(user_login_info_1.ip, "127.0.0.1");
-        assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
-        // Sign out.
-        connection.sign_out(&authentication_token_1).await?;
-
-        // Sign in.
-        let (authentication_token_2, user_id_2) = match connection
-            .sign_in(email, password, "192.168.1.1", "Chrome")
-            .await?
-        {
-            SignInResult::Ok(token, user_id) => (token, user_id),
-            other => panic!("{:?}", other),
-        };
-
-        assert_eq!(user_id_1, user_id_2);
-        assert_ne!(authentication_token_1, authentication_token_2);
-
-        // Check user login information.
-        let user_login_info_2 = connection
-            .get_user_login_info(&authentication_token_2)
-            .await?;
-
-        assert_eq!(user_login_info_2.ip, "192.168.1.1");
-        assert_eq!(user_login_info_2.user_agent, "Chrome");
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn ask_to_reset_password_for_unknown_email() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-
-        // Ask for password reset.
-        match connection
-            .get_token_reset_password(email, Duration::hours(1))
-            .await?
-        {
-            GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case.
-            other => panic!("{:?}", other),
-        }
-    }
-
-    #[tokio::test]
-    async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        let email = "paul@atreides.com";
-        let password = "12345";
-        let new_password = "54321";
-
-        // Sign up.
-        let validation_token = match connection.sign_up(email, password).await? {
-            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
-            other => panic!("{:?}", other),
-        };
-
-        // Validation.
-        let (authentication_token_1, user_id_1) = match connection
-            .validation(
-                &validation_token,
-                Duration::hours(1),
-                "127.0.0.1",
-                "Mozilla",
-            )
-            .await?
-        {
-            ValidationResult::Ok(token, user_id) => (token, user_id),
-            other => panic!("{:?}", other),
-        };
-
-        // Check user login information.
-        let user_login_info_1 = connection
-            .get_user_login_info(&authentication_token_1)
-            .await?;
-        assert_eq!(user_login_info_1.ip, "127.0.0.1");
-        assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
-        // Sign out.
-        connection.sign_out(&authentication_token_1).await?;
-
-        // Ask for password reset.
-        let token = match connection
-            .get_token_reset_password(email, Duration::hours(1))
-            .await?
-        {
-            GetTokenResetPasswordResult::Ok(token) => token,
-            other => panic!("{:?}", other),
-        };
-
-        connection
-            .reset_password(&new_password, &token, Duration::hours(1))
-            .await?;
+// #[cfg(test)]
+// mod tests {
+//     use super::*;
 
-        // Sign in.
-        let (authentication_token_2, user_id_2) = match connection
-            .sign_in(email, new_password, "192.168.1.1", "Chrome")
-            .await?
-        {
-            SignInResult::Ok(token, user_id) => (token, user_id),
-            other => panic!("{:?}", other),
-        };
-
-        assert_eq!(user_id_1, user_id_2);
-        assert_ne!(authentication_token_1, authentication_token_2);
-
-        // Check user login information.
-        let user_login_info_2 = connection
-            .get_user_login_info(&authentication_token_2)
-            .await?;
-
-        assert_eq!(user_login_info_2.ip, "192.168.1.1");
-        assert_eq!(user_login_info_2.user_agent, "Chrome");
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn update_user() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        connection.execute_sql(
-            sqlx::query(
-            r#"
-INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES
-    ($1, $2, $3, $4, $5, $6)
-            "#
-            )
-            .bind(1)
-            .bind("paul@atreides.com")
-            .bind("paul")
-            .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
-            .bind("2022-11-29 22:05:04.121407300+00:00")
-            .bind(None::<&str>) // 'null'.
-        ).await?;
-
-        let user = connection.load_user(1).await?.unwrap();
-
-        assert_eq!(user.name, "paul");
-        assert_eq!(user.email, "paul@atreides.com");
-
-        if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection
-            .update_user(
-                1,
-                Some("muaddib@fremen.com"),
-                Some("muaddib"),
-                Some("Chani"),
-            )
-            .await?
-        {
-            let (_authentication_token_1, user_id_1) = match connection
-                .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")
-                .await?
-            {
-                ValidationResult::Ok(token, user_id) => (token, user_id),
-                other => panic!("{:?}", other),
-            };
-            assert_eq!(user_id_1, 1);
-        } else {
-            panic!("A revalidation token must be created when changin e-mail");
-        }
-
-        let user = connection.load_user(1).await?.unwrap();
-
-        assert_eq!(user.name, "muaddib");
-        assert_eq!(user.email, "muaddib@fremen.com");
-
-        // Tests if password has been updated correctly.
-        if let SignInResult::Ok(_token, id) = connection
-            .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0")
-            .await?
-        {
-            assert_eq!(id, 1);
-        } else {
-            panic!("Can't sign in");
-        }
-
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
-        let connection = Connection::new_in_memory().await?;
-
-        connection.execute_sql(
-            sqlx::query(
-            r#"
-INSERT INTO [User]
-    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES
-    ($1, $2, $3, $4, $5, $6)
-            "#
-            )
-            .bind(1)
-            .bind("paul@atreides.com")
-            .bind("paul")
-            .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
-            .bind("2022-11-29 22:05:04.121407300+00:00")
-            .bind(None::<&str>) // 'null'.
-        ).await?;
-
-        match connection.create_recipe(2).await {
-            Err(DBError::Sqlx(sqlx::Error::Database(err))) => {
-                // SQLITE_CONSTRAINT_FOREIGNKEY
-                // https://www.sqlite.org/rescode.html#constraint_foreignkey
-                assert_eq!(err.code(), Some(std::borrow::Cow::from("787")));
-            } // Nominal case. TODO: check 'err' value.
-            other => panic!(
-                "Creating a recipe with an inexistant user must fail: {:?}",
-                other
-            ),
-        }
-
-        let recipe_id = connection.create_recipe(1).await?;
-        assert_eq!(recipe_id, 1);
-
-        connection.set_recipe_title(recipe_id, "Crêpe").await?;
-        let recipe = connection.get_recipe(recipe_id).await?.unwrap();
-        assert_eq!(recipe.title, "Crêpe".to_string());
-
-        Ok(())
-    }
-}
+// }
diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs
new file mode 100644 (file)
index 0000000..a942e7e
--- /dev/null
@@ -0,0 +1,125 @@
+use super::{model, Connection, DBError, Result};
+
+impl Connection {
+    pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
+        sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(DBError::from)
+    }
+
+    pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
+        sqlx::query_as(
+            r#"
+SELECT [id], [user_id], [title], [description]
+FROM [Recipe] WHERE [id] = $1
+            "#,
+        )
+        .bind(id)
+        .fetch_optional(&self.pool)
+        .await
+        .map_err(DBError::from)
+    }
+
+    pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
+        let mut tx = self.tx().await?;
+
+        match sqlx::query_scalar::<_, i64>(
+            r#"
+SELECT [Recipe].[id] FROM [Recipe]
+LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id]
+LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+WHERE [Recipe].[user_id] = $1
+    AND [Recipe].[title] = ''
+    AND [Recipe].[estimate_time] IS NULL
+    AND [Recipe].[description] = ''
+    AND [Image].[id] IS NULL
+    AND [Group].[id] IS NULL
+            "#,
+        )
+        .bind(user_id)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            Some(recipe_id) => Ok(recipe_id),
+            None => {
+                let db_result =
+                    sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')")
+                        .bind(user_id)
+                        .execute(&mut *tx)
+                        .await?;
+
+                tx.commit().await?;
+                Ok(db_result.last_insert_rowid())
+            }
+        }
+    }
+
+    pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> {
+        sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1")
+            .bind(recipe_id)
+            .bind(title)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+
+    pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> {
+        sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1")
+            .bind(recipe_id)
+            .bind(description)
+            .execute(&self.pool)
+            .await
+            .map(|_| ())
+            .map_err(DBError::from)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        connection.execute_sql(
+            sqlx::query(
+            r#"
+INSERT INTO [User]
+    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES
+    ($1, $2, $3, $4, $5, $6)
+            "#
+            )
+            .bind(1)
+            .bind("paul@atreides.com")
+            .bind("paul")
+            .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
+            .bind("2022-11-29 22:05:04.121407300+00:00")
+            .bind(None::<&str>) // 'null'.
+        ).await?;
+
+        match connection.create_recipe(2).await {
+            Err(DBError::Sqlx(sqlx::Error::Database(err))) => {
+                // SQLITE_CONSTRAINT_FOREIGNKEY
+                // https://www.sqlite.org/rescode.html#constraint_foreignkey
+                assert_eq!(err.code(), Some(std::borrow::Cow::from("787")));
+            } // Nominal case. TODO: check 'err' value.
+            other => panic!(
+                "Creating a recipe with an inexistant user must fail: {:?}",
+                other
+            ),
+        }
+
+        let recipe_id = connection.create_recipe(1).await?;
+        assert_eq!(recipe_id, 1);
+
+        connection.set_recipe_title(recipe_id, "Crêpe").await?;
+        let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+        assert_eq!(recipe.title, "Crêpe".to_string());
+
+        Ok(())
+    }
+}
diff --git a/backend/src/data/db/user.rs b/backend/src/data/db/user.rs
new file mode 100644 (file)
index 0000000..06d6353
--- /dev/null
@@ -0,0 +1,945 @@
+use chrono::{prelude::*, Duration};
+use rand::distributions::{Alphanumeric, DistString};
+use sqlx::Sqlite;
+
+use super::{model, Connection, DBError, Result};
+use crate::{
+    consts,
+    hash::{hash, verify_password},
+};
+
+#[derive(Debug)]
+pub enum SignUpResult {
+    UserAlreadyExists,
+    UserCreatedWaitingForValidation(String), // Validation token.
+}
+
+#[derive(Debug)]
+pub enum UpdateUserResult {
+    EmailAlreadyTaken,
+    UserUpdatedWaitingForRevalidation(String), // Validation token.
+    Ok,
+}
+
+#[derive(Debug)]
+pub enum ValidationResult {
+    UnknownUser,
+    ValidationExpired,
+    Ok(String, i64), // Returns token and user id.
+}
+
+#[derive(Debug)]
+pub enum SignInResult {
+    UserNotFound,
+    WrongPassword,
+    AccountNotValidated,
+    Ok(String, i64), // Returns token and user id.
+}
+
+#[derive(Debug)]
+pub enum AuthenticationResult {
+    NotValidToken,
+    Ok(i64), // Returns user id.
+}
+
+#[derive(Debug)]
+pub enum GetTokenResetPasswordResult {
+    PasswordAlreadyReset,
+    EmailUnknown,
+    Ok(String),
+}
+
+#[derive(Debug)]
+pub enum ResetPasswordResult {
+    ResetTokenExpired,
+    Ok,
+}
+
+fn generate_token() -> String {
+    Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
+}
+
+impl Connection {
+    #[cfg(test)]
+    pub async fn get_user_login_info(&self, token: &str) -> Result<model::UserLoginInfo> {
+        sqlx::query_as(
+            r#"
+SELECT [last_login_datetime], [ip], [user_agent]
+FROM [UserLoginToken] WHERE [token] = $1
+            "#,
+        )
+        .bind(token)
+        .fetch_one(&self.pool)
+        .await
+        .map_err(DBError::from)
+    }
+
+    pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
+        sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
+            .bind(user_id)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(DBError::from)
+    }
+
+    /// If a new email is given and it doesn't match the current one then it has to be
+    /// Revalidated.
+    pub async fn update_user(
+        &self,
+        user_id: i64,
+        new_email: Option<&str>,
+        new_name: Option<&str>,
+        new_password: Option<&str>,
+    ) -> Result<UpdateUserResult> {
+        let mut tx = self.tx().await?;
+        let hashed_new_password = new_password.map(|p| hash(p).unwrap());
+
+        let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>(
+            "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1",
+        )
+        .bind(user_id)
+        .fetch_one(&mut *tx)
+        .await?;
+
+        let email_changed = new_email.is_some_and(|new_email| new_email != email);
+
+        // Check if email not already taken.
+        let validation_token = if email_changed {
+            if sqlx::query_scalar::<_, i64>(
+                r#"
+SELECT COUNT(*)
+FROM [User]
+WHERE [email] = $1
+                "#,
+            )
+            .bind(new_email.unwrap())
+            .fetch_one(&mut *tx)
+            .await?
+                > 0
+            {
+                return Ok(UpdateUserResult::EmailAlreadyTaken);
+            }
+
+            let token = Some(generate_token());
+            sqlx::query(
+                r#"
+UPDATE [User]
+SET [validation_token] = $2, [validation_token_datetime] = $3
+WHERE [id] = $1
+            "#,
+            )
+            .bind(user_id)
+            .bind(&token)
+            .bind(Utc::now())
+            .execute(&mut *tx)
+            .await?;
+            token
+        } else {
+            None
+        };
+
+        sqlx::query(
+            r#"
+UPDATE [User]
+SET [email] = $2, [name] = $3, [password] = $4
+WHERE [id] = $1
+            "#,
+        )
+        .bind(user_id)
+        .bind(new_email.unwrap_or(&email))
+        .bind(new_name.unwrap_or(&name))
+        .bind(hashed_new_password.unwrap_or(hashed_password))
+        .execute(&mut *tx)
+        .await?;
+
+        tx.commit().await?;
+
+        Ok(if let Some(validation_token) = validation_token {
+            UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token)
+        } else {
+            UpdateUserResult::Ok
+        })
+    }
+
+    pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
+        self.sign_up_with_given_time(email, password, Utc::now())
+            .await
+    }
+
+    async fn sign_up_with_given_time(
+        &self,
+        email: &str,
+        password: &str,
+        datetime: DateTime<Utc>,
+    ) -> Result<SignUpResult> {
+        let mut tx = self.tx().await?;
+
+        let token = match sqlx::query_as::<_, (i64, Option<String>)>(
+            r#"
+SELECT [id], [validation_token]
+FROM [User] WHERE [email] = $1
+            "#,
+        )
+        .bind(email)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            Some((id, validation_token)) => {
+                if validation_token.is_none() {
+                    return Ok(SignUpResult::UserAlreadyExists);
+                }
+                let token = generate_token();
+                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+                sqlx::query(
+                    r#"
+UPDATE [User]
+SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4
+WHERE [id] = $1
+                    "#,
+                )
+                .bind(id)
+                .bind(&token)
+                .bind(datetime)
+                .bind(hashed_password)
+                .execute(&mut *tx)
+                .await?;
+                token
+            }
+            None => {
+                let token = generate_token();
+                let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+                sqlx::query(
+                    r#"
+INSERT INTO [User]
+([email], [validation_token], [validation_token_datetime], [password])
+VALUES ($1, $2, $3, $4)
+                    "#,
+                )
+                .bind(email)
+                .bind(&token)
+                .bind(datetime)
+                .bind(hashed_password)
+                .execute(&mut *tx)
+                .await?;
+                token
+            }
+        };
+
+        tx.commit().await?;
+
+        Ok(SignUpResult::UserCreatedWaitingForValidation(token))
+    }
+
+    pub async fn validation(
+        &self,
+        token: &str,
+        validation_time: Duration,
+        ip: &str,
+        user_agent: &str,
+    ) -> Result<ValidationResult> {
+        let mut tx = self.tx().await?;
+
+        // There is no index on [validation_token]. Is it useful?
+        let user_id = match sqlx::query_as::<_, (i64, DateTime<Utc>)>(
+            "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1",
+        )
+        .bind(token)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            Some((id, validation_token_datetime)) => {
+                if Utc::now() - validation_token_datetime > validation_time {
+                    return Ok(ValidationResult::ValidationExpired);
+                }
+                sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1")
+                    .bind(id)
+                    .execute(&mut *tx)
+                    .await?;
+                id
+            }
+            None => return Ok(ValidationResult::UnknownUser),
+        };
+
+        let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?;
+
+        tx.commit().await?;
+        Ok(ValidationResult::Ok(token, user_id))
+    }
+
+    pub async fn sign_in(
+        &self,
+        email: &str,
+        password: &str,
+        ip: &str,
+        user_agent: &str,
+    ) -> Result<SignInResult> {
+        let mut tx = self.tx().await?;
+        match sqlx::query_as::<_, (i64, String, Option<String>)>(
+            "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1",
+        )
+        .bind(email)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            Some((id, stored_password, validation_token)) => {
+                if validation_token.is_some() {
+                    Ok(SignInResult::AccountNotValidated)
+                } else if verify_password(password, &stored_password)
+                    .map_err(DBError::from_dyn_error)?
+                {
+                    let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?;
+                    tx.commit().await?;
+                    Ok(SignInResult::Ok(token, id))
+                } else {
+                    Ok(SignInResult::WrongPassword)
+                }
+            }
+            None => Ok(SignInResult::UserNotFound),
+        }
+    }
+
+    pub async fn authentication(
+        &self,
+        token: &str,
+        ip: &str,
+        user_agent: &str,
+    ) -> Result<AuthenticationResult> {
+        let mut tx = self.tx().await?;
+        match sqlx::query_as::<_, (i64, i64)>(
+            "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1",
+        )
+        .bind(token)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            Some((login_id, user_id)) => {
+                sqlx::query(
+                    r#"
+UPDATE [UserLoginToken]
+SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4
+WHERE [id] = $1
+                    "#,
+                )
+                .bind(login_id)
+                .bind(Utc::now())
+                .bind(ip)
+                .bind(user_agent)
+                .execute(&mut *tx)
+                .await?;
+                tx.commit().await?;
+                Ok(AuthenticationResult::Ok(user_id))
+            }
+            None => Ok(AuthenticationResult::NotValidToken),
+        }
+    }
+
+    pub async fn sign_out(&self, token: &str) -> Result<()> {
+        let mut tx = self.tx().await?;
+        match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
+            .bind(token)
+            .fetch_optional(&mut *tx)
+            .await?
+        {
+            Some(login_id) => {
+                sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
+                    .bind(login_id)
+                    .execute(&mut *tx)
+                    .await?;
+                tx.commit().await?;
+            }
+            None => (),
+        }
+        Ok(())
+    }
+
+    pub async fn get_token_reset_password(
+        &self,
+        email: &str,
+        validation_time: Duration,
+    ) -> Result<GetTokenResetPasswordResult> {
+        let mut tx = self.tx().await?;
+
+        if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
+            r#"
+SELECT [password_reset_datetime]
+FROM [User]
+WHERE [email] = $1
+            "#,
+        )
+        .bind(email)
+        .fetch_optional(&mut *tx)
+        .await?
+        {
+            if let Some(db_datetime) = db_datetime_nullable {
+                if Utc::now() - db_datetime <= validation_time {
+                    return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset);
+                }
+            }
+        } else {
+            return Ok(GetTokenResetPasswordResult::EmailUnknown);
+        }
+
+        let token = generate_token();
+
+        sqlx::query(
+            r#"
+UPDATE [User]
+SET [password_reset_token] = $2, [password_reset_datetime] = $3
+WHERE [email] = $1
+            "#,
+        )
+        .bind(email)
+        .bind(&token)
+        .bind(Utc::now())
+        .execute(&mut *tx)
+        .await?;
+
+        tx.commit().await?;
+
+        Ok(GetTokenResetPasswordResult::Ok(token))
+    }
+
+    pub async fn reset_password(
+        &self,
+        new_password: &str,
+        token: &str,
+        validation_time: Duration,
+    ) -> Result<ResetPasswordResult> {
+        let mut tx = self.tx().await?;
+        // There is no index on [password_reset_token]. Is it useful?
+        if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
+            r#"
+SELECT [id], [password_reset_datetime]
+FROM [User]
+WHERE [password_reset_token] = $1
+            "#,
+        )
+        .bind(token)
+        .fetch_one(&mut *tx)
+        .await?
+        {
+            if Utc::now() - db_datetime > validation_time {
+                return Ok(ResetPasswordResult::ResetTokenExpired);
+            }
+
+            // Remove all login tokens (for security reasons).
+            sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1")
+                .bind(user_id)
+                .execute(&mut *tx)
+                .await?;
+
+            let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
+
+            sqlx::query(
+                r#"
+UPDATE [User]
+SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL
+WHERE [id] = $1
+                "#,
+            )
+            .bind(user_id)
+            .bind(hashed_new_password)
+            .execute(&mut *tx)
+            .await?;
+
+            tx.commit().await?;
+
+            Ok(ResetPasswordResult::Ok)
+        } else {
+            Err(DBError::Other(
+                "Can't reset password: stored token or datetime not set (NULL)".to_string(),
+            ))
+        }
+    }
+
+    // Return the token.
+    async fn create_login_token(
+        tx: &mut sqlx::Transaction<'_, Sqlite>,
+        user_id: i64,
+        ip: &str,
+        user_agent: &str,
+    ) -> Result<String> {
+        let token = generate_token();
+        sqlx::query(
+            r#"
+INSERT INTO [UserLoginToken]
+([user_id], [last_login_datetime], [token], [ip], [user_agent])
+VALUES ($1, $2, $3, $4, $5)
+            "#,
+        )
+        .bind(user_id)
+        .bind(Utc::now())
+        .bind(&token)
+        .bind(ip)
+        .bind(user_agent)
+        .execute(&mut **tx)
+        .await?;
+        Ok(token)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[tokio::test]
+    async fn sign_up() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        match connection.sign_up("paul@atreides.com", "12345").await? {
+            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_to_an_already_existing_user() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        connection.execute_sql(
+            sqlx::query(
+            r#"
+INSERT INTO
+    [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+    VALUES (
+        1,
+        'paul@atreides.com',
+        'paul',
+        '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
+        0,
+        NULL
+    );
+            "#)).await?;
+        match connection.sign_up("paul@atreides.com", "12345").await? {
+            SignUpResult::UserAlreadyExists => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_and_sign_in_without_validation() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+        let password = "12345";
+
+        match connection.sign_up(email, password).await? {
+            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+
+        match connection
+            .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
+            .await?
+        {
+            SignInResult::AccountNotValidated => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        let token = generate_token();
+        connection.execute_sql(
+            sqlx::query(
+            r#"
+INSERT INTO [User]
+    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES (
+    1,
+    'paul@atreides.com',
+    'paul',
+    '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
+    0,
+    $1
+)
+            "#
+        ).bind(token)).await?;
+        match connection.sign_up("paul@atreides.com", "12345").await? {
+            SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_at_time() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+        match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla/5.0",
+            )
+            .await?
+        {
+            ValidationResult::Ok(_, _) => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_too_late() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        let validation_token = match connection
+            .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1))
+            .await?
+        {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+        match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla/5.0",
+            )
+            .await?
+        {
+            ValidationResult::ValidationExpired => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_with_bad_token() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+        let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+        let random_token = generate_token();
+        match connection
+            .validation(
+                &random_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla/5.0",
+            )
+            .await?
+        {
+            ValidationResult::UnknownUser => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_then_sign_in() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+        let password = "12345";
+
+        // Sign up.
+        let validation_token = match connection.sign_up(email, password).await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+
+        // Validation.
+        match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla/5.0",
+            )
+            .await?
+        {
+            ValidationResult::Ok(_, _) => (),
+            other => panic!("{:?}", other),
+        };
+
+        // Sign in.
+        match connection
+            .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
+            .await?
+        {
+            SignInResult::Ok(_, _) => (), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_then_authentication() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+        let password = "12345";
+
+        // Sign up.
+        let validation_token = match connection.sign_up(email, password).await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+
+        // Validation.
+        let (authentication_token, _user_id) = match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla",
+            )
+            .await?
+        {
+            ValidationResult::Ok(token, user_id) => (token, user_id),
+            other => panic!("{:?}", other),
+        };
+
+        // Check user login information.
+        let user_login_info_1 = connection
+            .get_user_login_info(&authentication_token)
+            .await?;
+        assert_eq!(user_login_info_1.ip, "127.0.0.1");
+        assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+        // Authentication.
+        let _user_id = match connection
+            .authentication(&authentication_token, "192.168.1.1", "Chrome")
+            .await?
+        {
+            AuthenticationResult::Ok(user_id) => user_id, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+
+        // Check user login information.
+        let user_login_info_2 = connection
+            .get_user_login_info(&authentication_token)
+            .await?;
+
+        assert_eq!(user_login_info_2.ip, "192.168.1.1");
+        assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+        let password = "12345";
+
+        // Sign up.
+        let validation_token = match connection.sign_up(email, password).await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+
+        // Validation.
+        let (authentication_token_1, user_id_1) = match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla",
+            )
+            .await?
+        {
+            ValidationResult::Ok(token, user_id) => (token, user_id),
+            other => panic!("{:?}", other),
+        };
+
+        // Check user login information.
+        let user_login_info_1 = connection
+            .get_user_login_info(&authentication_token_1)
+            .await?;
+        assert_eq!(user_login_info_1.ip, "127.0.0.1");
+        assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+        // Sign out.
+        connection.sign_out(&authentication_token_1).await?;
+
+        // Sign in.
+        let (authentication_token_2, user_id_2) = match connection
+            .sign_in(email, password, "192.168.1.1", "Chrome")
+            .await?
+        {
+            SignInResult::Ok(token, user_id) => (token, user_id),
+            other => panic!("{:?}", other),
+        };
+
+        assert_eq!(user_id_1, user_id_2);
+        assert_ne!(authentication_token_1, authentication_token_2);
+
+        // Check user login information.
+        let user_login_info_2 = connection
+            .get_user_login_info(&authentication_token_2)
+            .await?;
+
+        assert_eq!(user_login_info_2.ip, "192.168.1.1");
+        assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn ask_to_reset_password_for_unknown_email() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+
+        // Ask for password reset.
+        match connection
+            .get_token_reset_password(email, Duration::hours(1))
+            .await?
+        {
+            GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case.
+            other => panic!("{:?}", other),
+        }
+    }
+
+    #[tokio::test]
+    async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        let email = "paul@atreides.com";
+        let password = "12345";
+        let new_password = "54321";
+
+        // Sign up.
+        let validation_token = match connection.sign_up(email, password).await? {
+            SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+            other => panic!("{:?}", other),
+        };
+
+        // Validation.
+        let (authentication_token_1, user_id_1) = match connection
+            .validation(
+                &validation_token,
+                Duration::hours(1),
+                "127.0.0.1",
+                "Mozilla",
+            )
+            .await?
+        {
+            ValidationResult::Ok(token, user_id) => (token, user_id),
+            other => panic!("{:?}", other),
+        };
+
+        // Check user login information.
+        let user_login_info_1 = connection
+            .get_user_login_info(&authentication_token_1)
+            .await?;
+        assert_eq!(user_login_info_1.ip, "127.0.0.1");
+        assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+        // Sign out.
+        connection.sign_out(&authentication_token_1).await?;
+
+        // Ask for password reset.
+        let token = match connection
+            .get_token_reset_password(email, Duration::hours(1))
+            .await?
+        {
+            GetTokenResetPasswordResult::Ok(token) => token,
+            other => panic!("{:?}", other),
+        };
+
+        connection
+            .reset_password(&new_password, &token, Duration::hours(1))
+            .await?;
+
+        // Sign in.
+        let (authentication_token_2, user_id_2) = match connection
+            .sign_in(email, new_password, "192.168.1.1", "Chrome")
+            .await?
+        {
+            SignInResult::Ok(token, user_id) => (token, user_id),
+            other => panic!("{:?}", other),
+        };
+
+        assert_eq!(user_id_1, user_id_2);
+        assert_ne!(authentication_token_1, authentication_token_2);
+
+        // Check user login information.
+        let user_login_info_2 = connection
+            .get_user_login_info(&authentication_token_2)
+            .await?;
+
+        assert_eq!(user_login_info_2.ip, "192.168.1.1");
+        assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn update_user() -> Result<()> {
+        let connection = Connection::new_in_memory().await?;
+
+        connection.execute_sql(
+            sqlx::query(
+            r#"
+INSERT INTO [User]
+    ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES
+    ($1, $2, $3, $4, $5, $6)
+            "#
+            )
+            .bind(1)
+            .bind("paul@atreides.com")
+            .bind("paul")
+            .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
+            .bind("2022-11-29 22:05:04.121407300+00:00")
+            .bind(None::<&str>) // 'null'.
+        ).await?;
+
+        let user = connection.load_user(1).await?.unwrap();
+
+        assert_eq!(user.name, "paul");
+        assert_eq!(user.email, "paul@atreides.com");
+
+        if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection
+            .update_user(
+                1,
+                Some("muaddib@fremen.com"),
+                Some("muaddib"),
+                Some("Chani"),
+            )
+            .await?
+        {
+            let (_authentication_token_1, user_id_1) = match connection
+                .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")
+                .await?
+            {
+                ValidationResult::Ok(token, user_id) => (token, user_id),
+                other => panic!("{:?}", other),
+            };
+            assert_eq!(user_id_1, 1);
+        } else {
+            panic!("A revalidation token must be created when changin e-mail");
+        }
+
+        let user = connection.load_user(1).await?.unwrap();
+
+        assert_eq!(user.name, "muaddib");
+        assert_eq!(user.email, "muaddib@fremen.com");
+
+        // Tests if password has been updated correctly.
+        if let SignInResult::Ok(_token, id) = connection
+            .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0")
+            .await?
+        {
+            assert_eq!(id, 1);
+        } else {
+            panic!("Can't sign in");
+        }
+
+        Ok(())
+    }
+}
index 98b6ecd..c91ea29 100644 (file)
@@ -1,2 +1,3 @@
 pub mod db;
+pub mod model;
 mod utils;
diff --git a/backend/src/data/model.rs b/backend/src/data/model.rs
new file mode 100644 (file)
index 0000000..f52704c
--- /dev/null
@@ -0,0 +1,77 @@
+use chrono::prelude::*;
+
+#[derive(Debug, Clone)]
+pub struct User {
+    pub id: i64,
+    pub name: String,
+    pub email: String,
+}
+
+pub struct UserLoginInfo {
+    pub last_login_datetime: DateTime<Utc>,
+    pub ip: String,
+    pub user_agent: String,
+}
+
+pub struct Recipe {
+    pub id: i64,
+    pub user_id: i64,
+    pub title: String,
+    pub description: String,
+    pub estimate_time: Option<i32>, // [s].
+    pub difficulty: Difficulty,
+    pub lang: String,
+
+    //ingredients: Vec<Ingredient>, // For four people.
+    pub process: Vec<Group>,
+}
+
+impl Recipe {
+    pub fn empty(id: i64, user_id: i64) -> Recipe {
+        Self::new(id, user_id, String::new(), String::new())
+    }
+
+    pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {
+        Recipe {
+            id,
+            user_id,
+            title,
+            description,
+            estimate_time: None,
+            difficulty: Difficulty::Unknown,
+            lang: "en".to_string(),
+            process: Vec::new(),
+        }
+    }
+}
+
+pub struct Ingredient {
+    pub quantity: Option<Quantity>,
+    pub name: String,
+}
+
+pub struct Quantity {
+    pub value: f32,
+    pub unit: String,
+}
+
+pub struct Group {
+    pub name: Option<String>,
+    pub input: Vec<StepInput>,
+    pub steps: Vec<Step>,
+}
+
+pub struct Step {
+    pub action: String,
+}
+
+pub enum StepInput {
+    Ingredient(Ingredient),
+}
+
+pub enum Difficulty {
+    Unknown = 0,
+    Easy = 1,
+    Medium = 2,
+    Hard = 3,
+}
index a692892..73b6c1c 100644 (file)
@@ -1,6 +1,6 @@
 use sqlx::{sqlite::SqliteRow, FromRow, Row};
 
-use crate::model;
+use super::model;
 
 impl FromRow<'_, SqliteRow> for model::Recipe {
     fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
index 416f8c7..3f3fd4c 100644 (file)
@@ -1,22 +1,17 @@
 use askama::Template;
 
-use crate::model;
+use crate::data::model;
 
-#[derive(Template)]
-#[template(path = "home.html")]
-pub struct HomeTemplate {
-    pub user: Option<model::User>,
-    pub recipes: Vec<(i64, String)>,
-    pub current_recipe_id: Option<i64>,
+pub struct Recipes {
+    pub list: Vec<(i64, String)>,
+    pub current_id: Option<i64>,
 }
 
 #[derive(Template)]
-#[template(path = "view_recipe.html")]
-pub struct ViewRecipeTemplate {
+#[template(path = "home.html")]
+pub struct HomeTemplate {
     pub user: Option<model::User>,
-    pub recipes: Vec<(i64, String)>,
-    pub current_recipe_id: Option<i64>,
-    pub current_recipe: model::Recipe,
+    pub recipes: Recipes,
 }
 
 #[derive(Template)]
@@ -91,3 +86,19 @@ pub struct ProfileTemplate {
     pub message_email: String,
     pub message_password: String,
 }
+
+#[derive(Template)]
+#[template(path = "recipe_view.html")]
+pub struct RecipeViewTemplate {
+    pub user: Option<model::User>,
+    pub recipes: Recipes,
+    pub recipe: model::Recipe,
+}
+
+#[derive(Template)]
+#[template(path = "recipe_edit.html")]
+pub struct RecipeEditTemplate {
+    pub user: Option<model::User>,
+    pub recipes: Recipes,
+    pub recipe: model::Recipe,
+}
index 594d8d2..2c00ddc 100644 (file)
@@ -15,7 +15,7 @@ use config::Config;
 use tower_http::{services::ServeDir, trace::TraceLayer};
 use tracing::{event, Level};
 
-use data::db;
+use data::{db, model};
 
 mod config;
 mod consts;
@@ -23,7 +23,6 @@ mod data;
 mod email;
 mod hash;
 mod html_templates;
-mod model;
 mod ron_extractor;
 mod ron_utils;
 mod services;
@@ -111,6 +110,8 @@ async fn main() {
             get(services::reset_password_get).post(services::reset_password_post),
         )
         // Recipes.
+        .route("/recipe/new", get(services::create_recipe))
+        // .route("/recipe/edit/:id", get(services::edit_recipe))
         .route("/recipe/view/:id", get(services::view_recipe))
         // User.
         .route(
@@ -163,8 +164,8 @@ async fn get_current_user(
             .authentication(token_cookie.value(), &client_ip, &client_user_agent)
             .await
         {
-            Ok(db::AuthenticationResult::NotValidToken) => None,
-            Ok(db::AuthenticationResult::Ok(user_id)) => {
+            Ok(db::user::AuthenticationResult::NotValidToken) => None,
+            Ok(db::user::AuthenticationResult::Ok(user_id)) => {
                 match connection.load_user(user_id).await {
                     Ok(user) => user,
                     Err(error) => {
@@ -227,7 +228,7 @@ async fn process_args() -> bool {
                 // Set the creation datetime to 'now'.
                 con.execute_sql(
                     sqlx::query(
-                    "UPDATE [User] SET [creation_datetime] = ?1 WHERE [email] = 'paul@test.org'")
+                    "UPDATE [User] SET [validation_token_datetime] = $1 WHERE [email] = 'paul@test.org'")
                     .bind(Utc::now())
                 )
                 .await
diff --git a/backend/src/model.rs b/backend/src/model.rs
deleted file mode 100644 (file)
index 2cf53e8..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-use chrono::prelude::*;\r
-\r
-#[derive(Debug, Clone)]\r
-pub struct User {\r
-    pub id: i64,\r
-    pub name: String,\r
-    pub email: String,\r
-}\r
-\r
-pub struct UserLoginInfo {\r
-    pub last_login_datetime: DateTime<Utc>,\r
-    pub ip: String,\r
-    pub user_agent: String,\r
-}\r
-\r
-pub struct Recipe {\r
-    pub id: i64,\r
-    pub user_id: i64,\r
-    pub title: String,\r
-    pub description: String,\r
-    pub estimate_time: Option<i32>, // [min].\r
-    pub difficulty: Difficulty,\r
-\r
-    //ingredients: Vec<Ingredient>, // For four people.\r
-    pub process: Vec<Group>,\r
-}\r
-\r
-impl Recipe {\r
-    pub fn empty(id: i64, user_id: i64) -> Recipe {\r
-        Self::new(id, user_id, String::new(), String::new())\r
-    }\r
-\r
-    pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {\r
-        Recipe {\r
-            id,\r
-            user_id,\r
-            title,\r
-            description,\r
-            estimate_time: None,\r
-            difficulty: Difficulty::Unknown,\r
-            process: Vec::new(),\r
-        }\r
-    }\r
-}\r
-\r
-pub struct Ingredient {\r
-    pub quantity: Option<Quantity>,\r
-    pub name: String,\r
-}\r
-\r
-pub struct Quantity {\r
-    pub value: f32,\r
-    pub unit: String,\r
-}\r
-\r
-pub struct Group {\r
-    pub name: Option<String>,\r
-    pub input: Vec<StepInput>,\r
-    pub output: Vec<IntermediateSubstance>,\r
-    pub steps: Vec<Step>,\r
-}\r
-\r
-pub struct Step {\r
-    pub action: String,\r
-}\r
-\r
-pub struct IntermediateSubstance {\r
-    pub name: String,\r
-    pub quantity: Option<Quantity>,\r
-}\r
-\r
-pub enum StepInput {\r
-    Ingredient(Ingredient),\r
-    IntermediateSubstance(IntermediateSubstance),\r
-}\r
-\r
-pub enum Difficulty {\r
-    Unknown,\r
-    Easy,\r
-    Medium,\r
-    Hard,\r
-}\r
index 2991d86..a14eda4 100644 (file)
@@ -15,7 +15,12 @@ use serde::Deserialize;
 use tracing::{event, Level};
 
 use crate::{
-    config::Config, consts, data::db, email, html_templates::*, model, ron_utils, utils, AppState,
+    config::Config,
+    consts,
+    data::{db, model},
+    email,
+    html_templates::*,
+    ron_utils, utils, AppState,
 };
 
 pub mod ron;
@@ -53,12 +58,41 @@ pub async fn home_page(
 
     Ok(HomeTemplate {
         user,
-        current_recipe_id: None,
-        recipes,
+        recipes: Recipes {
+            list: recipes,
+            current_id: None,
+        }, // current_recipe_id: None,
+           // recipes,
     })
 }
 
-///// VIEW RECIPE /////
+///// RECIPE /////
+
+#[debug_handler]
+pub async fn create_recipe(
+    State(connection): State<db::Connection>,
+    Extension(user): Extension<Option<model::User>>,
+) -> Result<Response> {
+    if let Some(user) = user {
+        let recipe_id = connection.create_recipe(user.id).await?;
+        Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
+    } else {
+        Ok(MessageTemplate::new("Not logged in").into_response())
+    }
+}
+
+// #[debug_handler]
+// pub async fn edit_recipe(
+//     State(connection): State<db::Connection>,
+//     Extension(user): Extension<Option<model::User>>,
+//     Path(recipe_id): Path<i64>,
+// ) -> Result<Response> {
+//     if let Some(user) = user {
+//         Ok(RecipeEditTemplate { user }.into_response())
+//     } else {
+//         Ok(MessageTemplate::new("Not logged in").into_response())
+//     }
+// }
 
 #[debug_handler]
 pub async fn view_recipe(
@@ -68,11 +102,13 @@ pub async fn view_recipe(
 ) -> Result<Response> {
     let recipes = connection.get_all_recipe_titles().await?;
     match connection.get_recipe(recipe_id).await? {
-        Some(recipe) => Ok(ViewRecipeTemplate {
+        Some(recipe) => Ok(RecipeViewTemplate {
             user,
-            current_recipe_id: Some(recipe.id),
-            recipes,
-            current_recipe: recipe,
+            recipes: Recipes {
+                list: recipes,
+                current_id: Some(recipe.id),
+            },
+            recipe,
         }
         .into_response()),
         None => Ok(MessageTemplate::new_with_user(
@@ -173,10 +209,10 @@ pub async fn sign_up_post(
         .sign_up(&form_data.email, &form_data.password_1)
         .await
     {
-        Ok(db::SignUpResult::UserAlreadyExists) => {
+        Ok(db::user::SignUpResult::UserAlreadyExists) => {
             error_response(SignUpError::UserAlreadyExists, &form_data, user)
         }
-        Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => {
+        Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
             let url = utils::get_url_from_host(&host);
             let email = form_data.email.clone();
             match email::send_email(
@@ -236,7 +272,7 @@ pub async fn sign_up_validation(
                 )
                 .await?
             {
-                db::ValidationResult::Ok(token, user_id) => {
+                db::user::ValidationResult::Ok(token, user_id) => {
                     let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
                     jar = jar.add(cookie);
                     let user = connection.load_user(user_id).await?;
@@ -248,14 +284,14 @@ pub async fn sign_up_validation(
                         ),
                     ))
                 }
-                db::ValidationResult::ValidationExpired => Ok((
+                db::user::ValidationResult::ValidationExpired => Ok((
                     jar,
                     MessageTemplate::new_with_user(
                         "The validation has expired. Try to sign up again",
                         user,
                     ),
                 )),
-                db::ValidationResult::UnknownUser => Ok((
+                db::user::ValidationResult::UnknownUser => Ok((
                     jar,
                     MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
                 )),
@@ -307,7 +343,7 @@ pub async fn sign_in_post(
         )
         .await?
     {
-        db::SignInResult::AccountNotValidated => Ok((
+        db::user::SignInResult::AccountNotValidated => Ok((
             jar,
             SignInFormTemplate {
                 user,
@@ -316,7 +352,7 @@ pub async fn sign_in_post(
             }
             .into_response(),
         )),
-        db::SignInResult::UserNotFound | db::SignInResult::WrongPassword => Ok((
+        db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
             jar,
             SignInFormTemplate {
                 user,
@@ -325,7 +361,7 @@ pub async fn sign_in_post(
             }
             .into_response(),
         )),
-        db::SignInResult::Ok(token, _user_id) => {
+        db::user::SignInResult::Ok(token, _user_id) => {
             let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
             Ok((jar.add(cookie), Redirect::to("/").into_response()))
         }
@@ -433,15 +469,15 @@ pub async fn ask_reset_password_post(
         )
         .await
     {
-        Ok(db::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
+        Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
             AskResetPasswordError::EmailAlreadyReset,
             &form_data.email,
             user,
         ),
-        Ok(db::GetTokenResetPasswordResult::EmailUnknown) => {
+        Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
             error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
         }
-        Ok(db::GetTokenResetPasswordResult::Ok(token)) => {
+        Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
             let url = utils::get_url_from_host(&host);
             match email::send_email(
                 &form_data.email,
@@ -559,12 +595,12 @@ pub async fn reset_password_post(
         )
         .await
     {
-        Ok(db::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
+        Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
             "Your password has been reset",
             user,
         )
         .into_response()),
-        Ok(db::ResetPasswordResult::ResetTokenExpired) => {
+        Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
             error_response(ResetPasswordError::TokenExpired, &form_data, user)
         }
         Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
@@ -681,10 +717,10 @@ pub async fn edit_user_post(
             )
             .await
         {
-            Ok(db::UpdateUserResult::EmailAlreadyTaken) => {
+            Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
                 return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
             }
-            Ok(db::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
+            Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
                 let url = utils::get_url_from_host(&host);
                 let email = form_data.email.clone();
                 match email::send_email(
@@ -709,7 +745,7 @@ pub async fn edit_user_post(
                     }
                 }
             }
-            Ok(db::UpdateUserResult::Ok) => {
+            Ok(db::user::UpdateUserResult::Ok) => {
                 message = "Profile saved";
             }
             Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
@@ -760,7 +796,7 @@ pub async fn email_revalidation(
                 )
                 .await?
             {
-                db::ValidationResult::Ok(token, user_id) => {
+                db::user::ValidationResult::Ok(token, user_id) => {
                     let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
                     jar = jar.add(cookie);
                     let user = connection.load_user(user_id).await?;
@@ -769,14 +805,14 @@ pub async fn email_revalidation(
                         MessageTemplate::new_with_user("Email validation successful", user),
                     ))
                 }
-                db::ValidationResult::ValidationExpired => Ok((
+                db::user::ValidationResult::ValidationExpired => Ok((
                     jar,
                     MessageTemplate::new_with_user(
                         "The validation has expired. Try to sign up again with the same email",
                         user,
                     ),
                 )),
-                db::ValidationResult::UnknownUser => Ok((
+                db::user::ValidationResult::UnknownUser => Ok((
                     jar,
                     MessageTemplate::new_with_user(
                         "Validation error. Try to sign up again with the same email",
index 7a4e937..76d84ac 100644 (file)
@@ -7,14 +7,14 @@
 {% block main_container %}
     <nav class="recipes-list">
         <ul>
-            {% for (id, title) in recipes %}
+            {% for (id, title) in recipes.list %}
                 <li>
-                    {% match current_recipe_id %}
+                    {% match recipes.current_id %}
                         {# Don't know how to avoid
-                           repetition: comparing (using '==' or .eq()) current_recipe_id.unwrap() and id doesn't work.
+                           repetition: comparing (using '==' or .eq()) recipes.current_recipe_id.unwrap() and id doesn't work.
                            Guards for match don't exist.
                            See: https://github.com/djc/askama/issues/752 #}
-                        {% when Some (current_id) %}
+                        {% when Some(current_id) %}
                             {% if current_id == id %}
                                 {% call recipe_item(id, title, "recipe-item-current") %}
                             {% else %}
index f999c41..28f5a80 100644 (file)
@@ -6,7 +6,7 @@
     id="title_field"
     type="text"
     name="title"
-    value="{{ current_recipe.title }}"
+    value="{{ recipe.title }}"
     autocapitalize="none"
     autocomplete="title"
     autofocus="autofocus" />
@@ -16,7 +16,7 @@
     id="title_field"
     type="text"
     name="title"
-    value="{{ current_recipe.description }}"
+    value="{{ recipe.description }}"
     autocapitalize="none"
     autocomplete="title"
     autofocus="autofocus" />
diff --git a/backend/templates/recipe_view.html b/backend/templates/recipe_view.html
new file mode 100644 (file)
index 0000000..e94c38e
--- /dev/null
@@ -0,0 +1,18 @@
+{% extends "base_with_list.html" %}
+
+{% block content %}
+
+<h2 class="recipe-title" >{{ recipe.title }}</h2>
+
+
+{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
+    <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
+{% endif %}
+
+{% if !recipe.description.is_empty() %}
+    <div class="recipe-description" >
+    {{ recipe.description.clone()|markdown }}
+    </div>
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
index 4500d79..d7c5c27 100644 (file)
         {{ message_email }}
 
         <label for="input-password-1">Choose a password (minimum 8 characters)</label>
-        <input id="input-password-1" type="password" name="password_1" />
+        <input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
 
         <label for="input-password-2">Re-enter password</label>
-        <input id="input-password-2" type="password" name="password_2" />
+        <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
 
         {{ message_password }}
 
diff --git a/backend/templates/view_recipe.html b/backend/templates/view_recipe.html
deleted file mode 100644 (file)
index aba5c69..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends "base_with_list.html" %}
-
-{% block content %}
-
-<h2 class="recipe-title" >{{ current_recipe.title }}</h2>
-
-
-{% if user.is_some() && current_recipe.user_id == user.as_ref().unwrap().id %}
-    <a class="edit-recipe" href="/recipe/edit/{{ current_recipe.id }}" >Edit</a>
-{% endif %}
-
-{% if !current_recipe.description.is_empty() %}
-    <div class="recipe-description" >
-    {{ current_recipe.description.clone()|markdown }}
-    </div>
-{% endif %}
-
-{% endblock %}
\ No newline at end of file