about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 02:42:02 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-03-19 02:42:02 +0100
commit6df299c87ba8faab75626d04392f874ec642c8dc (patch)
tree64744ae2dea2c726d72589ce290e88679e60c564
parentchore(rocie-client): Re-generate the client api (diff)
downloadserver-6df299c87ba8faab75626d04392f874ec642c8dc.zip
feat(rocie-server): Provide default units (and other changes)
-rw-r--r--crates/rocie-server/src/api/set/no_auth/user.rs43
-rw-r--r--crates/rocie-server/src/app.rs10
-rw-r--r--crates/rocie-server/src/storage/migrate/defaults.rs99
-rw-r--r--crates/rocie-server/src/storage/migrate/mod.rs72
-rw-r--r--crates/rocie-server/src/storage/migrate/sql/0->1.sql7
-rw-r--r--crates/rocie-server/src/storage/sql/config.rs7
-rw-r--r--crates/rocie-server/src/storage/sql/get/config/mod.rs41
-rw-r--r--crates/rocie-server/src/storage/sql/get/mod.rs3
-rw-r--r--crates/rocie-server/src/storage/sql/get/unit/mod.rs40
-rw-r--r--crates/rocie-server/src/storage/sql/insert/config/mod.rs80
-rw-r--r--crates/rocie-server/src/storage/sql/insert/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/mod.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs100
-rw-r--r--crates/rocie-server/tests/_testenv/init.rs21
-rw-r--r--crates/rocie-server/tests/defaults/mod.rs48
-rw-r--r--crates/rocie-server/tests/recipies/mod.rs178
-rw-r--r--crates/rocie-server/tests/tests.rs1
-rw-r--r--crates/rocie-server/tests/users/mod.rs13
-rw-r--r--nix/package.nix162
19 files changed, 728 insertions, 199 deletions
diff --git a/crates/rocie-server/src/api/set/no_auth/user.rs b/crates/rocie-server/src/api/set/no_auth/user.rs
index 7ca865c..69758a7 100644
--- a/crates/rocie-server/src/api/set/no_auth/user.rs
+++ b/crates/rocie-server/src/api/set/no_auth/user.rs
@@ -6,9 +6,13 @@ use utoipa::ToSchema;
 use crate::{
     api::set::auth::user::UserStub,
     app::App,
-    storage::sql::{
-        insert::Operations,
-        user::{PasswordHash, User, UserId},
+    storage::{
+        migrate::migrate_db,
+        sql::{
+            config::Config,
+            insert::Operations,
+            user::{PasswordHash, User, UserId},
+        },
     },
 };
 
@@ -84,6 +88,14 @@ async fn logout(user: Identity) -> impl Responder {
     HttpResponse::Ok()
 }
 
+#[derive(ToSchema, Deserialize)]
+struct ProvisionInfo {
+    user: UserStub,
+
+    /// Whether we should apply the default configuration.
+    use_defaults: bool,
+}
+
 /// Provision this instance.
 ///
 /// This only works, if no users exist yet.
@@ -104,24 +116,39 @@ async fn logout(user: Identity) -> impl Responder {
             body = String
         )
     ),
-    request_body = UserStub,
+    request_body = ProvisionInfo,
 )]
 #[post("/provision")]
 async fn provision(
     request: HttpRequest,
     app: web::Data<App>,
-    new_user: web::Json<UserStub>,
+    info: web::Json<ProvisionInfo>,
 ) -> Result<impl Responder> {
     if User::get_all(&app).await?.is_empty() {
-        let user = new_user.into_inner();
+        let info = info.into_inner();
 
         let mut ops = Operations::new("register user (during provisioning)");
 
-        let password_hash = PasswordHash::from_password(&user.password);
-        let user = User::register(user.name, password_hash, user.description, &mut ops);
+        let password_hash = PasswordHash::from_password(&info.user.password);
+        let user = User::register(
+            info.user.name,
+            password_hash,
+            info.user.description,
+            &mut ops,
+        );
 
         ops.apply(&app).await?;
 
+        if info.use_defaults {
+            let mut ops =
+                Operations::new("Set should use defaults on config (during provisioning)");
+            let mut config = Config::get(&app).await?;
+            config.set_use_default(true, &mut ops);
+            ops.apply(&app).await?;
+
+            migrate_db(&app).await?;
+        }
+
         Identity::login(&request.extensions(), user.id.to_string())?;
 
         Ok(HttpResponse::Ok().json(user.id))
diff --git a/crates/rocie-server/src/app.rs b/crates/rocie-server/src/app.rs
index bb27470..59eed28 100644
--- a/crates/rocie-server/src/app.rs
+++ b/crates/rocie-server/src/app.rs
@@ -1,12 +1,13 @@
-use std::path::PathBuf;
+use std::{cell::OnceCell, path::PathBuf, sync::OnceLock};
 
 use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
 
-use crate::storage::migrate::migrate_db;
+use crate::storage::migrate::{DbVersion, migrate_db};
 
 #[derive(Clone)]
 pub(crate) struct App {
     pub(crate) db: SqlitePool,
+    pub(crate) db_version_at_start: OnceLock<DbVersion>,
 }
 
 impl App {
@@ -25,7 +26,10 @@ impl App {
             })?
         };
 
-        let me = Self { db };
+        let me = Self {
+            db,
+            db_version_at_start: OnceLock::new(),
+        };
 
         migrate_db(&me).await?;
 
diff --git a/crates/rocie-server/src/storage/migrate/defaults.rs b/crates/rocie-server/src/storage/migrate/defaults.rs
new file mode 100644
index 0000000..3a2019c
--- /dev/null
+++ b/crates/rocie-server/src/storage/migrate/defaults.rs
@@ -0,0 +1,99 @@
+use crate::{
+    app::App,
+    storage::sql::{insert::Operations, unit::Unit, unit_property::UnitProperty},
+};
+
+#[expect(clippy::unnecessary_wraps, reason = "The API expects an Option<_>")]
+fn so(input: &'static str) -> Option<String> {
+    Some(s(input))
+}
+fn s(input: &'static str) -> String {
+    String::from(input)
+}
+
+macro_rules! register {
+    (
+        $app:ident # $unit_prop_description:literal,  $unit_prop_name:literal
+        $(
+            -> $unit_full_name_singular:ident, $unit_full_name_plural:ident, $unit_short_name:ident
+        )*
+    ) => {
+        let mut ops = Operations::new(concat!(
+            "create",
+            $unit_prop_name,
+            "unit property (during provisioning)"
+        ));
+        let unit_property = UnitProperty::register(
+            s($unit_prop_name),
+            so($unit_prop_description),
+            &mut ops,
+        )
+        .id;
+        ops.apply(&$app).await?;
+
+        let mut ops = Operations::new(concat!(
+            "create default units for",
+            $unit_prop_name,
+            "property (during provisioning)"
+        ));
+
+        $(
+            Unit::register(
+                s(stringify!($unit_full_name_singular)),
+                s(stringify!($unit_full_name_plural)),
+                s(stringify!($unit_short_name)),
+                None,
+                unit_property,
+                &mut ops,
+            );
+        )*
+
+        ops.apply(&$app).await?;
+    };
+}
+
+pub(super) async fn add_defaults_0_to_1(app: &App) -> Result<(), apply_defaults::Error> {
+    register!(
+        app # "Time mesurement units", "Time"
+        -> second, seconds, s
+        -> minute, minutes, min
+        -> hour, hours, h
+    );
+
+    register!(
+        app # "Mass (weight) mesurement units", "Mass"
+        -> milligram, milligrams, mg
+        -> gram, grams, g
+        -> kilogram, kilograms, kg
+    );
+
+    register!(
+        app # "Volume mesurement units", "Volume"
+        -> milliliter, millilters, ml
+        -> deciliter, deciliters, dl
+        -> liter, liters, l
+
+        // English
+        -> tablespoon, tablespoons, tbsp
+        -> teaspoon, teaspoons, tsp
+
+        // Swedish
+        -> tesked, teskedar, tsk
+        -> matsked, matskedar, msk
+    );
+
+    Ok(())
+}
+
+pub(crate) mod apply_defaults {
+    use crate::storage::sql::insert::{self, apply};
+
+    #[derive(thiserror::Error, Debug)]
+    pub(crate) enum Error {
+        #[error("Failed to add new default unit-property the database: {0}")]
+        AddUnitProperty(#[from] apply::Error<insert::unit_property::Operation>),
+
+        #[error("Failed to add new default unit the database: {0}")]
+        AddUnit(#[from] apply::Error<insert::unit::Operation>),
+    }
+}
diff --git a/crates/rocie-server/src/storage/migrate/mod.rs b/crates/rocie-server/src/storage/migrate/mod.rs
index ae0732b..5c81580 100644
--- a/crates/rocie-server/src/storage/migrate/mod.rs
+++ b/crates/rocie-server/src/storage/migrate/mod.rs
@@ -7,10 +7,15 @@ use chrono::TimeDelta;
 use log::{debug, info};
 use sqlx::{Sqlite, SqlitePool, Transaction, query};
 
-use crate::app::App;
+use crate::{
+    app::App,
+    storage::sql::{config::Config, get::config},
+};
+
+mod defaults;
 
 macro_rules! make_upgrade {
-    ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => {
+    ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => {{
         let mut tx = $app
             .db
             .begin()
@@ -57,8 +62,8 @@ macro_rules! make_upgrade {
                 new_version: $new_version,
             })?;
 
-        Ok(())
-    };
+        Ok::<_, update::Error>(())
+    }};
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
@@ -135,11 +140,12 @@ impl DbVersion {
     ///
     /// Each update is atomic, so if this function fails you are still guaranteed to have a
     /// database at version `get_version`.
-    #[allow(clippy::too_many_lines)]
     async fn update(self, app: &App) -> Result<(), update::Error> {
         match self {
             Self::Empty => {
-                make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"}
+                make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"}?;
+
+                Ok(())
             }
 
             // This is the current_version
@@ -158,7 +164,10 @@ impl Display for DbVersion {
     }
 }
 pub(crate) mod update {
-    use crate::storage::migrate::{DbVersion, db_version_set, get_db_version};
+    use crate::storage::{
+        migrate::{DbVersion, db_version_set, defaults, get_db_version},
+        sql::get::config,
+    };
 
     #[derive(thiserror::Error, Debug)]
     pub(crate) enum Error {
@@ -182,6 +191,11 @@ pub(crate) mod update {
         #[error("Failed to commit the update transaction: {0}")]
         TxnCommit(sqlx::Error),
 
+        #[error("Failed to access the rocie config: {0}")]
+        ConfigGet(#[from] config::get::Error),
+        #[error("Failed to add defaults to the database: {0}")]
+        AddDefaults(#[from] defaults::apply_defaults::Error),
+
         #[error("Failed to perform the next chained update (to ver {new_version}): {err}")]
         NextUpdate {
             err: Box<Self>,
@@ -197,6 +211,12 @@ pub(crate) mod db_version_parse {
     }
 }
 
+async fn should_use_defaults(app: &App) -> Result<bool, config::get::Error> {
+    let config = Config::get(app).await?;
+
+    Ok(config.should_use_defaults)
+}
+
 /// Returns the current data as UNIX time stamp.
 pub(crate) fn get_current_date() -> i64 {
     let start = SystemTime::now();
@@ -238,7 +258,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d
         )
         .fetch_optional(pool)
         .await
-        .map_err(|err| get_db_version::Error::VersionTableExistance(err))?;
+        .map_err(get_db_version::Error::VersionTableExistance)?;
 
         if let Some(output) = query {
             assert_eq!(output.result, 1);
@@ -261,7 +281,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d
     )
     .fetch_one(pool)
     .await
-    .map_err(|err| get_db_version::Error::VersionNumberFetch(err))?;
+    .map_err(get_db_version::Error::VersionNumberFetch)?;
 
     Ok(DbVersion::from_db(
         current_version.number,
@@ -288,6 +308,26 @@ pub(crate) mod get_db_version {
 pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> {
     let current_version = get_version(app).await?;
 
+    if app.db_version_at_start.get().is_none() {
+        app.db_version_at_start
+            .set(current_version)
+            .expect("the cell to be unititialized, we checked");
+    }
+
+    if app.db_version_at_start.get() == Some(&DbVersion::Empty) {
+        // We cannot run this code in the normal update function, because for the empty db, there
+        // is no way to know if we want defaults.
+        //
+        // So we need to add the defaults later, which is achieved by another call to `migrate_db`
+        // in provision.
+        //
+        // That is kinda hacky, but I don't see a way around it.
+        // For defaults added in version Two and onward, the default mechanism should just work.
+        if should_use_defaults(app).await? {
+            defaults::add_defaults_0_to_1(app).await?;
+        }
+    }
+
     if current_version == CURRENT_VERSION {
         return Ok(());
     }
@@ -300,7 +340,12 @@ pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> {
 }
 
 pub(crate) mod migrate_db {
-    use crate::storage::migrate::{get_db_version, update};
+    use actix_web::ResponseError;
+
+    use crate::storage::{
+        migrate::{defaults, get_db_version, update},
+        sql::get::config,
+    };
 
     #[derive(thiserror::Error, Debug)]
     pub(crate) enum Error {
@@ -309,5 +354,12 @@ pub(crate) mod migrate_db {
 
         #[error("Failed to update the database: {0}")]
         Upadate(#[from] update::Error),
+
+        #[error("Failed to access the rocie config: {0}")]
+        ConfigGet(#[from] config::get::Error),
+        #[error("Failed to add defaults to the database: {0}")]
+        AddDefaults(#[from] defaults::apply_defaults::Error),
     }
+
+    impl ResponseError for Error {}
 }
diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
index ba44c68..8f99322 100644
--- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql
+++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
@@ -178,3 +178,10 @@ CREATE TABLE txn_log (
     timestamp INTEGER NOT NULL,
     operation TEXT    NOT NULL
 ) STRICT;
+
+CREATE TABLE config (
+    -- Make it impossible to insert more than one value here.
+    id           INTEGER PRIMARY KEY NOT NULL CHECK (id = 0),
+    use_defaults INTEGER             NOT NULL CHECK (use_defaults = 1 OR use_defaults = 0)
+) STRICT;
+INSERT INTO config (id, use_defaults) VALUES (0, 0);
diff --git a/crates/rocie-server/src/storage/sql/config.rs b/crates/rocie-server/src/storage/sql/config.rs
new file mode 100644
index 0000000..d62859c
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/config.rs
@@ -0,0 +1,7 @@
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)]
+pub(crate) struct Config {
+    pub(crate) should_use_defaults: bool,
+}
diff --git a/crates/rocie-server/src/storage/sql/get/config/mod.rs b/crates/rocie-server/src/storage/sql/get/config/mod.rs
new file mode 100644
index 0000000..eb8be86
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/get/config/mod.rs
@@ -0,0 +1,41 @@
+use crate::{app::App, storage::sql::config::Config};
+
+use sqlx::query;
+
+impl Config {
+    pub(crate) async fn get(app: &App) -> Result<Self, get::Error> {
+        let record = query!(
+            "
+        SELECT use_defaults
+        FROM config
+        WHERE id = 0
+"
+        )
+        .fetch_one(&app.db)
+        .await?;
+
+        let should_use_defaults = if record.use_defaults == 1 {
+            true
+        } else if record.use_defaults == 0 {
+            false
+        } else {
+            unreachable!("Should not be possible, sqlite's CHECK prevents it")
+        };
+
+        Ok(Self {
+            should_use_defaults,
+        })
+    }
+}
+
+pub(crate) mod get {
+    use actix_web::ResponseError;
+
+    #[derive(thiserror::Error, Debug)]
+    pub(crate) enum Error {
+        #[error("Failed to execute the sql query")]
+        SqlError(#[from] sqlx::Error),
+    }
+
+    impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs
index a6ee0e1..e3520da 100644
--- a/crates/rocie-server/src/storage/sql/get/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/mod.rs
@@ -1,9 +1,10 @@
 pub(crate) mod barcode;
+pub(crate) mod config;
 pub(crate) mod product;
 pub(crate) mod product_amount;
 pub(crate) mod product_parent;
-pub(crate) mod recipe_parent;
 pub(crate) mod recipe;
+pub(crate) mod recipe_parent;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
 pub(crate) mod user;
diff --git a/crates/rocie-server/src/storage/sql/get/unit/mod.rs b/crates/rocie-server/src/storage/sql/get/unit/mod.rs
index 6f5d297..2c85970 100644
--- a/crates/rocie-server/src/storage/sql/get/unit/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/unit/mod.rs
@@ -57,6 +57,34 @@ impl Unit {
             Ok(None)
         }
     }
+
+    pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_name::Error> {
+        let record = query!(
+            "
+        SELECT id, full_name_singular, unit_property, full_name_plural, short_name, description
+        FROM units
+        WHERE full_name_singular = ? OR full_name_plural = ? OR short_name = ?
+",
+            name,
+            name,
+            name
+        )
+        .fetch_optional(&app.db)
+        .await?;
+
+        if let Some(record) = record {
+            Ok(Some(Self {
+                id: UnitId::from_db(&record.id),
+                unit_property: UnitPropertyId::from_db(&record.unit_property),
+                full_name_singular: record.full_name_singular,
+                full_name_plural: record.full_name_plural,
+                short_name: record.short_name,
+                description: record.description,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
 }
 
 pub(crate) mod get_all {
@@ -82,3 +110,15 @@ pub(crate) mod from_id {
 
     impl ResponseError for Error {}
 }
+
+pub(crate) mod from_name {
+    use actix_web::ResponseError;
+
+    #[derive(thiserror::Error, Debug)]
+    pub(crate) enum Error {
+        #[error("Failed to execute the sql query")]
+        SqlError(#[from] sqlx::Error),
+    }
+
+    impl ResponseError for Error {}
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/config/mod.rs b/crates/rocie-server/src/storage/sql/insert/config/mod.rs
new file mode 100644
index 0000000..8c81feb
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/insert/config/mod.rs
@@ -0,0 +1,80 @@
+use serde::{Deserialize, Serialize};
+use sqlx::query;
+
+use crate::storage::sql::{
+    config::Config,
+    insert::{Operations, Transactionable},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) enum Operation {
+    UseDefault { value: bool, old_value: bool },
+}
+
+impl Transactionable for Operation {
+    type ApplyError = apply::Error;
+    type UndoError = undo::Error;
+
+    async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> {
+        match self {
+            Operation::UseDefault { value, .. } => {
+                query!(
+                    "
+                   UPDATE config
+                   SET use_defaults = ?
+                   WHERE id = 0
+",
+                    value,
+                )
+                .execute(txn)
+                .await?;
+            }
+        }
+        Ok(())
+    }
+
+    async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
+        match self {
+            Operation::UseDefault { old_value, .. } => {
+                query!(
+                    "
+                   UPDATE config
+                   SET use_defaults = ?
+                   WHERE id = 0
+",
+                    old_value,
+                )
+                .execute(txn)
+                .await?;
+            }
+        }
+        Ok(())
+    }
+}
+
+pub(crate) mod undo {
+    #[derive(thiserror::Error, Debug)]
+    pub(crate) enum Error {
+        #[error("Failed to execute undo sql statments: {0}")]
+        SqlError(#[from] sqlx::Error),
+    }
+}
+pub(crate) mod apply {
+    #[derive(thiserror::Error, Debug)]
+    pub(crate) enum Error {
+        #[error("Failed to execute apply sql statments: {0}")]
+        SqlError(#[from] sqlx::Error),
+    }
+}
+
+impl Config {
+    pub(crate) fn set_use_default(&mut self, new_value: bool, ops: &mut Operations<Operation>) {
+        if self.should_use_defaults != new_value {
+            ops.push(Operation::UseDefault {
+                value: new_value,
+                old_value: self.should_use_defaults,
+            });
+            self.should_use_defaults = new_value;
+        }
+    }
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs
index b92a88c..c106b2b 100644
--- a/crates/rocie-server/src/storage/sql/insert/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/mod.rs
@@ -8,6 +8,7 @@ use serde::{Serialize, de::DeserializeOwned};
 use sqlx::{SqliteConnection, query};
 
 pub(crate) mod barcode;
+pub(crate) mod config;
 pub(crate) mod product;
 pub(crate) mod product_parent;
 pub(crate) mod recipe;
diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs
index c37e68f..f1d7cb1 100644
--- a/crates/rocie-server/src/storage/sql/mod.rs
+++ b/crates/rocie-server/src/storage/sql/mod.rs
@@ -3,6 +3,7 @@ pub(crate) mod insert;
 
 // Types
 pub(crate) mod barcode;
+pub(crate) mod config;
 pub(crate) mod product;
 pub(crate) mod product_amount;
 pub(crate) mod product_parent;
diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs
index 1fc3b56..7347b4b 100644
--- a/crates/rocie-server/src/storage/sql/recipe.rs
+++ b/crates/rocie-server/src/storage/sql/recipe.rs
@@ -1,5 +1,6 @@
 #![expect(clippy::unused_async)]
 
+use log::error;
 use serde::{Deserialize, Serialize};
 use utoipa::ToSchema;
 
@@ -10,7 +11,7 @@ use crate::{
         product::{Product, ProductId},
         product_amount::ProductAmount,
         recipe_parent::RecipeParentId,
-        unit::UnitAmount,
+        unit::{Unit, UnitAmount},
     },
 };
 
@@ -97,7 +98,6 @@ pub(crate) struct Section {
 
 /// Each type of content inside a section
 #[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
-#[serde(tag = "type", content = "value", rename_all = "camelCase")]
 pub(crate) enum Content {
     /// A step
     Step(Step),
@@ -124,7 +124,6 @@ pub(crate) struct Step {
 /// Except for [`Item::Text`], the value is the index where the item is located
 /// in it's corresponding [`Vec`] in the [`Recipe`].
 #[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
-#[serde(tag = "type", rename_all = "camelCase")]
 pub(crate) enum Item {
     /// Just plain text
     Text {
@@ -205,7 +204,8 @@ pub(crate) struct Timer {
     pub(crate) name: Option<String>,
 
     /// Time quantity
-    pub(crate) quantity: UnitAmount,
+    #[schema(nullable = false)]
+    pub(crate) quantity: Option<UnitAmount>,
 }
 
 #[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
@@ -238,12 +238,18 @@ pub(crate) struct Metadata {
 }
 
 pub(crate) mod conversion {
-    use crate::storage::sql::get::product::from_id;
+    use crate::storage::sql::get::{product::from_id, unit::from_name};
 
     #[derive(thiserror::Error, Debug)]
     pub(crate) enum Error {
         #[error("Failed to get a product by id: `{0}`")]
         ProductAccess(#[from] from_id::Error),
+
+        #[error("The quantity of a cookware has an unit: `{0}`")]
+        CookwareQtyHasUnit(cooklang::Quantity),
+
+        #[error("Failed to get a unit by name: `{0}`")]
+        UnitLookup(#[from] from_name::Error),
     }
 }
 
@@ -258,8 +264,8 @@ pub(crate) struct NameAndUrl {
 impl NameAndUrl {
     async fn from(value: cooklang::metadata::NameAndUrl) -> Result<Self, conversion::Error> {
         Ok(Self {
-            name: value.name().map(|v| v.to_owned()),
-            url: value.url().map(|v| v.to_owned()),
+            name: value.name().map(ToOwned::to_owned),
+            url: value.url().map(ToOwned::to_owned),
         })
     }
 }
@@ -274,7 +280,7 @@ impl CooklangRecipe {
             sections: for_in!(value.sections, |s| Section::from(s).await),
             ingredients: for_in!(value.ingredients, |i| Ingredient::from(app, i).await),
             cookware: for_in!(value.cookware, |c| Cookware::from(c).await),
-            timers: for_in!(value.timers, |t| Timer::from(t).await),
+            timers: for_in!(value.timers, |t| Timer::from(app, t).await),
         })
     }
 }
@@ -353,16 +359,22 @@ impl Ingredient {
     async fn from(app: &App, value: cooklang::Ingredient) -> Result<Self, conversion::Error> {
         if value.name.starts_with('/') {
             Ok(Self::RecipeReference { id: todo!() })
-        } else if let Some(product) = Product::from_name(&app, value.name.as_str()).await? {
+        } else if let Some(product) = Product::from_name(app, value.name.as_str()).await? {
             Ok(Self::RegisteredProduct {
                 id: product.id,
                 alias: value.alias,
-                quantity: None,
+                quantity: {
+                    let unit_amount = quantity_to_unit_amount(app, value.quantity).await?;
+                    unit_amount.map(|ua| ProductAmount {
+                        product_id: product.id,
+                        amount: ua,
+                    })
+                },
             })
         } else {
             Ok(Self::NotRegisteredProduct {
                 name: value.name,
-                quantity: None,
+                quantity: quantity_to_unit_amount(app, value.quantity).await?,
             })
         }
     }
@@ -372,20 +384,72 @@ impl Cookware {
         Ok(Self {
             name: value.name,
             alias: value.alias,
-            quantity: value.quantity.map(|q| match q.value() {
-                cooklang::Value::Number(number) => number.value() as usize,
-                cooklang::Value::Range { start, end } => todo!(),
-                cooklang::Value::Text(_) => todo!(),
-            }),
+            quantity: if let Some(qty) = value.quantity {
+                if qty.unit().is_some() {
+                    return Err(conversion::Error::CookwareQtyHasUnit(qty));
+                }
+
+                let var_name = match qty.value() {
+                    cooklang::Value::Number(number) => number.value() as usize,
+                    cooklang::Value::Range { .. } | cooklang::Value::Text(_) => todo!(),
+                };
+
+                Some(var_name)
+            } else {
+                None
+            },
             note: value.note,
         })
     }
 }
 impl Timer {
-    async fn from(value: cooklang::Timer) -> Result<Self, conversion::Error> {
+    async fn from(app: &App, value: cooklang::Timer) -> Result<Self, conversion::Error> {
         Ok(Self {
             name: value.name,
-            quantity: todo!(),
+            quantity: quantity_to_unit_amount(app, value.quantity).await?,
         })
     }
 }
+
+async fn quantity_to_unit_amount(
+    app: &App,
+    qty: Option<cooklang::Quantity>,
+) -> Result<Option<UnitAmount>, conversion::Error> {
+    if let Some(qty) = qty {
+        let amount: f64 = match qty.value() {
+            cooklang::Value::Number(number) => number.value(),
+            cooklang::Value::Range { start, end } => {
+                // Let's just assume that more is better than less?
+                // TODO: This should be mapped correctly <2026-03-15>
+
+                end.value()
+            }
+            cooklang::Value::Text(_) => {
+                // TODO: Is there maybe a better way to deal with, non-parsable quantities? <2026-03-15>
+                return Ok(None);
+            }
+        };
+
+        let unit = if let Some(unit_name) = qty.unit() {
+            let unit = Unit::from_name(app, unit_name).await?;
+
+            if unit.is_none() {
+                error!(
+                    "Failed to transfer the quantity for a recipe amount, as the unit is not yet known: {unit_name}"
+                );
+            }
+
+            unit
+        } else {
+            return Ok(None);
+        };
+
+        Ok(unit.map(|unit| UnitAmount {
+            // TODO: We need to convert the unit to the one that can fit the full f64 value as u32. <2026-03-15>
+            value: amount as u32,
+            unit: unit.id,
+        }))
+    } else {
+        Ok(None)
+    }
+}
diff --git a/crates/rocie-server/tests/_testenv/init.rs b/crates/rocie-server/tests/_testenv/init.rs
index 4a169fe..758ca4e 100644
--- a/crates/rocie-server/tests/_testenv/init.rs
+++ b/crates/rocie-server/tests/_testenv/init.rs
@@ -21,7 +21,7 @@ use std::{
 
 use rocie_client::{
     apis::{api_set_no_auth_user_api::provision, configuration::Configuration},
-    models::UserStub,
+    models::{ProvisionInfo, UserStub},
 };
 
 use crate::{
@@ -116,10 +116,15 @@ impl TestEnv {
 
         request!(
             env,
-            provision(UserStub {
-                description: Some("Test user, used during test runs".to_string()),
-                name: "rocie".to_string(),
-                password: "server".to_string()
+            provision(ProvisionInfo {
+                user: UserStub {
+                    description: Some("Test user, used during test runs".to_string()),
+                    name: "rocie".to_string(),
+                    password: "server".to_string()
+                },
+                // Don't use any default units.
+                // Otherwise we would need to update the tests every time we add new ones.
+                use_defaults: false,
             })
         );
 
@@ -150,7 +155,11 @@ impl TestEnv {
                 let mut stdout = BufReader::new(child.stdout.as_mut().expect("Was captured"));
 
                 let mut port = String::new();
-                assert_ne!(stdout.read_line(&mut port).expect("Works"), 0);
+                assert_ne!(
+                    stdout.read_line(&mut port).expect("Works"),
+                    0,
+                    "We should have been able to read the one line, the server printed"
+                );
 
                 port.trim_end()
                     .parse()
diff --git a/crates/rocie-server/tests/defaults/mod.rs b/crates/rocie-server/tests/defaults/mod.rs
new file mode 100644
index 0000000..6a334b0
--- /dev/null
+++ b/crates/rocie-server/tests/defaults/mod.rs
@@ -0,0 +1,48 @@
+use rocie_client::{
+    apis::{api_get_auth_unit_api::units, api_set_no_auth_user_api::provision},
+    models::{ProvisionInfo, UserStub},
+};
+
+use crate::testenv::{TestEnv, init::function_name, log::request};
+
+#[tokio::test]
+async fn test_defaults_disabled() {
+    let env = TestEnv::new_no_login(function_name!());
+
+    request!(
+        env,
+        provision(ProvisionInfo {
+            user: UserStub {
+                description: None,
+                name: "James Richard Haynes".to_string(),
+                password: "hunter14".to_string()
+            },
+            use_defaults: false,
+        })
+    );
+
+    let default_units = request!(env, units());
+
+    assert_eq!(default_units, vec![]);
+}
+
+#[tokio::test]
+async fn test_defaults_all() {
+    let env = TestEnv::new_no_login(function_name!());
+
+    request!(
+        env,
+        provision(ProvisionInfo {
+            user: UserStub {
+                description: None,
+                name: "James Richard Haynes".to_string(),
+                password: "hunter14".to_string()
+            },
+            use_defaults: true,
+        })
+    );
+
+    let default_units = request!(env, units());
+
+    assert!(!default_units.is_empty());
+}
diff --git a/crates/rocie-server/tests/recipies/mod.rs b/crates/rocie-server/tests/recipies/mod.rs
index e59c3f6..15680f1 100644
--- a/crates/rocie-server/tests/recipies/mod.rs
+++ b/crates/rocie-server/tests/recipies/mod.rs
@@ -1,13 +1,14 @@
 use rocie_client::{
     apis::{
-        api_get_auth_recipe_api::recipe_by_id, api_set_auth_product_api::register_product,
+        api_get_auth_recipe_api::{recipe_by_id, recipe_by_name},
+        api_set_auth_product_api::register_product,
         api_set_auth_recipe_api::add_recipe,
         api_set_auth_unit_property_api::register_unit_property,
     },
     models::{
         Content, ContentOneOf, CooklangRecipe, Ingredient, IngredientOneOf1NotRegisteredProduct,
-        Item, ItemOneOf, ItemOneOf1, Metadata, NameAndUrl, ProductStub, RecipeStub, Section, Step,
-        UnitPropertyStub,
+        Item, ItemOneOf, ItemOneOf1, ItemOneOf1Ingredient, ItemOneOf3, ItemOneOfText, Metadata,
+        NameAndUrl, ProductStub, RecipeStub, Section, Step, UnitPropertyStub,
     },
 };
 
@@ -74,7 +75,28 @@ title: Curry
 }
 
 #[tokio::test]
-async fn test_recipe_ingredients() {
+async fn test_recipe_whitespace() {
+    let env = TestEnv::new(function_name!()).await;
+
+    let name = "      Curry  ".to_owned();
+
+    request!(
+        env,
+        add_recipe(RecipeStub {
+            content: " nothing really ".to_owned(),
+            name: name.clone(),
+            parent: None,
+        })
+    );
+
+    let output = request!(env, recipe_by_name(&name));
+
+    assert_eq!(output.name, name);
+}
+
+#[tokio::test]
+#[expect(clippy::too_many_lines)]
+async fn test_recipe_contents() {
     let env = TestEnv::new(function_name!()).await;
 
     let up = request!(
@@ -104,6 +126,8 @@ author: James Connor
 title: Curry
 ---
 Add @rice{} and @water{200%ml} to a pot.
+
+Now add @curry-spice{200%g} let rest for ~{20%min}.
 "
             .to_owned(),
             name: "Curry".to_owned(),
@@ -130,41 +154,131 @@ Add @rice{} and @water{200%ml} to a pot.
                     quantity: None,
                 }
             }),
+            Ingredient::IngredientOneOf1(rocie_client::models::IngredientOneOf1 {
+                not_registered_product: IngredientOneOf1NotRegisteredProduct {
+                    name: "curry-spice".to_owned(),
+                    quantity: None,
+                }
+            }),
         ]
     );
 
     assert_eq!(
         output.content.sections,
         vec![Section {
-            content: vec![Content::ContentOneOf(ContentOneOf {
-                value: Step {
-                    items: vec![
-                        Item::ItemOneOf(ItemOneOf {
-                            r#type: rocie_client::models::item_one_of::Type::Text,
-                            value: "Add ".to_owned()
-                        }),
-                        Item::ItemOneOf1(ItemOneOf1 {
-                            r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
-                            index: 0
-                        }),
-                        Item::ItemOneOf(ItemOneOf {
-                            r#type: rocie_client::models::item_one_of::Type::Text,
-                            value: " and ".to_owned()
-                        }),
-                        Item::ItemOneOf1(ItemOneOf1 {
-                            r#type: rocie_client::models::item_one_of_1::Type::Ingredient,
-                            index: 1
-                        }),
-                        Item::ItemOneOf(ItemOneOf {
-                            r#type: rocie_client::models::item_one_of::Type::Text,
-                            value: " to a pot.".to_owned()
-                        })
-                    ],
-                    number: 1
-                },
-                r#type: rocie_client::models::content_one_of::Type::Step
-            })],
+            content: vec![
+                Content::ContentOneOf(ContentOneOf {
+                    step: Step {
+                        items: vec![
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: "Add ".to_owned()
+                                }
+                            }),
+                            Item::ItemOneOf1(ItemOneOf1 {
+                                ingredient: ItemOneOf1Ingredient { index: 0 }
+                            }),
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: " and ".to_owned()
+                                }
+                            }),
+                            Item::ItemOneOf1(ItemOneOf1 {
+                                ingredient: ItemOneOf1Ingredient { index: 1 }
+                            }),
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: " to a pot.".to_owned()
+                                }
+                            })
+                        ],
+                        number: 1
+                    }
+                }),
+                Content::ContentOneOf(ContentOneOf {
+                    step: Step {
+                        items: vec![
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: "Now add ".to_owned()
+                                }
+                            }),
+                            Item::ItemOneOf1(ItemOneOf1 {
+                                ingredient: ItemOneOf1Ingredient { index: 2 }
+                            }),
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: " let rest for ".to_owned()
+                                }
+                            }),
+                            Item::ItemOneOf3(ItemOneOf3 {
+                                timer: ItemOneOf1Ingredient { index: 0 }
+                            }),
+                            Item::ItemOneOf(ItemOneOf {
+                                text: ItemOneOfText {
+                                    value: ".".to_owned()
+                                }
+                            })
+                        ],
+                        number: 2
+                    }
+                })
+            ],
             name: None
         }]
     );
 }
+
+#[tokio::test]
+async fn test_recipe_full_parse() {
+    let env = TestEnv::new(function_name!()).await;
+
+    // Recipe source: https://cook.md/https://bbcgoodfood.com/recipes/easy-pancakes
+    let recipe_id = request!(
+        env,
+        add_recipe(RecipeStub {
+            content: "
+---
+title: Easy pancakes
+description: Learn how to make the perfect pancakes every time with our foolproof easy crêpe recipe – elaborate flip optional
+image: https://images.immediate.co.uk/production/volatile/sites/30/2020/08/recipe-image-legacy-id-1273477_8-ad36e3b.jpg?resize=440,400
+nutrition:
+  calories: 61 calories
+  fat: 2 grams fat
+  saturated fat: 1 grams saturated fat
+  carbohydrates: 7 grams carbohydrates
+  sugar: 1 grams sugar
+  protein: 3 grams protein
+  sodium: 0.1 milligram of sodium
+tags: Cassie Best, Cook school, Crepe, Crêpes, easy pancakes, Flip, Flipping, Good for you, healthy pancakes, How to make pancakes, Make ahead, Pancake day, Pancake filling, Shrove Tuesday, Skills, thin pancakes
+source: https://bbcgoodfood.com/recipes/easy-pancakes
+author: Cassie Best
+prep time: 10 minutes
+course: Breakfast, Brunch, Main course
+time required: 30 minutes
+cook time: 20 minutes
+servings: Makes 12
+cuisine: British
+diet: Vegetarian
+---
+
+Put @plain flour{100%g}, @eggs{2}(large), @milk{300%ml}, @sunflower oil{1%tbsp} and a pinch of @salt{} into a #bowl{} or large jug, then whisk to a smooth batter. This should be similar in consistency to single cream.
+
+Set aside for ~{30%minutes} to rest if you have time, or start cooking straight away.
+
+Set a #medium frying pan{} or #crêpe pan{} over a medium heat and carefully wipe it with some oiled kitchen paper.
+
+When hot, cook your pancakes for ~{1%minute} on each side until golden, using around half a ladleful of batter per pancake. Keep them warm in a low oven as you make the rest.
+
+Serve with @?lemon wedges{} (optional) and @?caster sugar{} (optional), or your favourite filling. Once cold, you can layer the pancakes between baking parchment, then wrap in cling film and freeze for up to two months.
+"
+            .to_owned(),
+            name: "Easy pancakes".to_owned(),
+            parent: None,
+        })
+    );
+
+    let output = request!(env, recipe_by_id(recipe_id));
+
+    assert_eq!(output.name, "Easy pancakes".to_owned());
+}
diff --git a/crates/rocie-server/tests/tests.rs b/crates/rocie-server/tests/tests.rs
index e788712..6222017 100644
--- a/crates/rocie-server/tests/tests.rs
+++ b/crates/rocie-server/tests/tests.rs
@@ -9,3 +9,4 @@ mod recipe_parents;
 mod recipies;
 mod units;
 mod users;
+mod defaults;
diff --git a/crates/rocie-server/tests/users/mod.rs b/crates/rocie-server/tests/users/mod.rs
index d381e8f..c7ba6f8 100644
--- a/crates/rocie-server/tests/users/mod.rs
+++ b/crates/rocie-server/tests/users/mod.rs
@@ -4,7 +4,7 @@ use rocie_client::{
         api_set_auth_user_api::register_user,
         api_set_no_auth_user_api::{login, logout, provision},
     },
-    models::{LoginInfo, UserStub},
+    models::{LoginInfo, ProvisionInfo, UserStub},
 };
 
 use crate::testenv::{TestEnv, init::function_name, log::request};
@@ -15,10 +15,13 @@ async fn test_provisioning() {
 
     let user_id = request!(
         env,
-        provision(UserStub {
-            description: None,
-            name: "James Richard Haynes".to_string(),
-            password: "hunter14".to_string()
+        provision(ProvisionInfo {
+            user: UserStub {
+                description: None,
+                name: "James Richard Haynes".to_string(),
+                password: "hunter14".to_string()
+            },
+            use_defaults: false,
         })
     );
 
diff --git a/nix/package.nix b/nix/package.nix
index f3d16bf..d0efb16 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -11,129 +11,59 @@
 {
   lib,
   rustPlatform,
-  installShellFiles,
-  # buildInputs
-  mpv-unwrapped,
-  ffmpeg,
-  openssl,
-  libffi,
-  zlib,
-  curl,
-  # NativeBuildInputs
-  makeWrapper,
-  llvmPackages_latest,
-  glibc,
-  sqlite,
-  fd,
+  # nativeBuildInputs
   pkg-config,
-  SDL2,
-  python3,
-  # Passthru
-  tree-sitter-yts,
-}: let
-  python = python3.withPackages (ps: [ps.yt-dlp]);
-in
-  rustPlatform.buildRustPackage (finalAttrs: {
-    pname = "yt";
-    inherit
-      ((builtins.fromTOML (builtins.readFile
-            ../Cargo.toml)).workspace.package)
-      version
-      ;
-
-    src = lib.cleanSourceWith {
-      src = lib.cleanSource ./..;
-      filter = name: type:
-        (type == "directory")
-        || (builtins.elem (builtins.baseNameOf name) [
-          "Cargo.toml"
-          "Cargo.lock"
-          "mkdb.sh"
-          "help.str"
-          "raw_error_warning.txt"
-          "golden.txt"
-        ])
-        || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name))
-        || (lib.strings.hasSuffix ".h" (builtins.baseNameOf name))
-        || (lib.strings.hasSuffix ".sql" (builtins.baseNameOf name));
-    };
-
-    nativeBuildInputs = [
-      installShellFiles
-      makeWrapper
-      sqlite
-      fd
-      pkg-config
-    ];
-
-    buildInputs = [
-      mpv-unwrapped.dev
-      ffmpeg
-      openssl
-      libffi
-      zlib
-      curl.dev
-      python
-    ];
-
-    checkInputs = [
-      # Needed for the tests in `libmpv2`
-      SDL2
-    ];
-
-    env = let
-      clang_version =
-        lib.versions.major
-        llvmPackages_latest.clang-unwrapped.version;
-    in {
-      # Needed for the compile time sqlite checks.
-      DATABASE_URL = "sqlite://database.sqlx";
+  sqlite,
+}:
+rustPlatform.buildRustPackage (finalAttrs: {
+  pname = "rocie-server";
+  inherit
+    ((builtins.fromTOML (builtins.readFile
+          ../Cargo.toml)).workspace.package)
+    version
+    ;
 
-      # Required by yt_dlp
-      FFMPEG_LOCATION = "${lib.getExe ffmpeg}";
+  src = lib.cleanSourceWith {
+    src = lib.cleanSource ./..;
+    filter = name: type:
+      (type == "directory")
+      || (builtins.elem (builtins.baseNameOf name) [
+        "Cargo.toml"
+        "Cargo.lock"
+        "mkdb.sh"
+      ])
+      || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name))
+      || (lib.strings.hasSuffix ".sql" (builtins.baseNameOf name));
+  };
 
-      # Tell pyo3 which python to use.
-      PYO3_PYTHON = lib.getExe python;
+  nativeBuildInputs = [
+    pkg-config
+    sqlite
+  ];
 
-      # Needed for the libmpv2.
-      C_INCLUDE_PATH = "${glibc.dev}/include";
-      LIBCLANG_INCLUDE_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/clang/${clang_version}/include";
-      LIBCLANG_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/libclang.so";
-    };
+  buildInputs = [
+  ];
 
-    doCheck = true;
-    checkFlags = [
-      # All of these tests try to connect to the internet to download test data.
-      "--skip=select::base::test_base"
-      "--skip=select::file::test_file"
-      "--skip=select::options::test_options"
-      "--skip=subscriptions::import_export::test_import_export"
-      "--skip=subscriptions::naming_subscriptions::test_naming_subscriptions"
-      "--skip=videos::downloading::test_downloading"
-    ];
+  checkInputs = [
+  ];
 
-    prePatch = ''
-      # Generate the sqlite db, so that we can run the comp-time sqlite checks.
-      bash ./scripts/mkdb.sh
-    '';
+  env = {
+    # Needed for the compile time sqlite checks.
+    DATABASE_URL = "sqlite://database.sqlx";
+  };
 
-    passthru = {
-      inherit tree-sitter-yts;
-    };
+  doCheck = true;
 
-    cargoLock = {
-      lockFile = ../Cargo.lock;
-    };
+  prePatch = ''
+    # Generate the sqlite db, so that we can run the comp-time sqlite checks.
+    bash ./scripts/mkdb.sh
+  '';
 
-    postInstall = ''
-      installShellCompletion --cmd yt \
-        --bash <(COMPLETE=bash $out/bin/yt) \
-        --fish <(COMPLETE=fish $out/bin/yt) \
-        --zsh <(COMPLETE=zsh $out/bin/yt)
+  cargoLock = {
+    lockFile = ../Cargo.lock;
+  };
 
-      # NOTE: We cannot clear the path, because we need access to the $EDITOR. <2025-04-04>
-      wrapProgram $out/bin/yt \
-        --prefix PATH : ${lib.makeBinPath finalAttrs.buildInputs} \
-        --set YTDLP_NO_PLUGINS 1
-    '';
-  })
+  meta = {
+    mainProgram = "rocie-server";
+  };
+})