about summary refs log tree commit diff stats
path: root/crates/rocie-server/src
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-11-28 16:30:02 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-11-28 16:30:02 +0100
commita62ab5c6dacaddb67931d7ac160bc7faaa707737 (patch)
treea35fa3540fbb89f575ab1ea72f9b23ace399e01c /crates/rocie-server/src
parentchore(crates/rocie-client): Re-generate (diff)
downloadserver-a62ab5c6dacaddb67931d7ac160bc7faaa707737.zip
feat(crates/rocie-server): Get closer to feature parity between rocie and grocy
Diffstat (limited to 'crates/rocie-server/src')
-rw-r--r--crates/rocie-server/src/api/get/mod.rs13
-rw-r--r--crates/rocie-server/src/api/get/product.rs119
-rw-r--r--crates/rocie-server/src/api/get/product_parent.rs64
-rw-r--r--crates/rocie-server/src/api/get/recipe.rs66
-rw-r--r--crates/rocie-server/src/api/get/unit.rs46
-rw-r--r--crates/rocie-server/src/api/set/mod.rs4
-rw-r--r--crates/rocie-server/src/api/set/product.rs7
-rw-r--r--crates/rocie-server/src/api/set/product_parent.rs60
-rw-r--r--crates/rocie-server/src/api/set/recipe.rs54
-rw-r--r--crates/rocie-server/src/api/set/unit.rs2
-rw-r--r--crates/rocie-server/src/api/set/unit_property.rs4
-rw-r--r--crates/rocie-server/src/main.rs40
-rw-r--r--crates/rocie-server/src/storage/migrate/sql/0->1.sql10
-rw-r--r--crates/rocie-server/src/storage/sql/get/mod.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/get/product/mod.rs85
-rw-r--r--crates/rocie-server/src/storage/sql/get/product_parent/mod.rs87
-rw-r--r--crates/rocie-server/src/storage/sql/get/recipe/mod.rs78
-rw-r--r--crates/rocie-server/src/storage/sql/insert/mod.rs2
-rw-r--r--crates/rocie-server/src/storage/sql/insert/product/mod.rs6
-rw-r--r--crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs113
-rw-r--r--crates/rocie-server/src/storage/sql/insert/recipe/mod.rs95
-rw-r--r--crates/rocie-server/src/storage/sql/mod.rs35
-rw-r--r--crates/rocie-server/src/storage/sql/product.rs9
-rw-r--r--crates/rocie-server/src/storage/sql/product_parent.rs31
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs18
-rw-r--r--crates/rocie-server/src/storage/sql/unit.rs1
-rw-r--r--crates/rocie-server/src/storage/sql/unit_property.rs1
27 files changed, 986 insertions, 66 deletions
diff --git a/crates/rocie-server/src/api/get/mod.rs b/crates/rocie-server/src/api/get/mod.rs
index 1c32105..487b55c 100644
--- a/crates/rocie-server/src/api/get/mod.rs
+++ b/crates/rocie-server/src/api/get/mod.rs
@@ -2,6 +2,8 @@ use actix_web::web;
 
 pub(crate) mod inventory;
 pub(crate) mod product;
+pub(crate) mod product_parent;
+pub(crate) mod recipe;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
 
@@ -9,8 +11,17 @@ pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) {
     cfg.service(product::product_by_id)
         .service(product::product_by_name)
         .service(product::product_suggestion_by_name)
-        .service(product::products)
+        .service(product::products_registered)
+        .service(product::products_in_storage)
+        .service(product::products_by_product_parent_id_indirect)
+        .service(product::products_by_product_parent_id_direct)
+        .service(product_parent::product_parents)
+        .service(product_parent::product_parents_toplevel)
+        .service(product_parent::product_parents_under)
+        .service(recipe::recipe_by_id)
+        .service(recipe::recipes)
         .service(unit::units)
+        .service(unit::units_by_property_id)
         .service(unit::unit_by_id)
         .service(unit_property::unit_properties)
         .service(unit_property::unit_property_by_id)
diff --git a/crates/rocie-server/src/api/get/product.rs b/crates/rocie-server/src/api/get/product.rs
index 55e5d91..4216f9b 100644
--- a/crates/rocie-server/src/api/get/product.rs
+++ b/crates/rocie-server/src/api/get/product.rs
@@ -4,7 +4,11 @@ use percent_encoding::percent_decode_str;
 
 use crate::{
     app::App,
-    storage::sql::product::{Product, ProductId, ProductIdStub},
+    storage::sql::{
+        product::{Product, ProductId, ProductIdStub},
+        product_amount::ProductAmount,
+        product_parent::{ProductParent, ProductParentId, ProductParentIdStub},
+    },
 };
 
 /// A String, that is not url-decoded on parse.
@@ -65,7 +69,7 @@ pub(crate) async fn product_by_name(
     req: HttpRequest,
     name: web::Path<String>,
 ) -> Result<impl Responder> {
-    let _name = name;
+    drop(name);
 
     let name = UrlEncodedString::from_str(
         req.path()
@@ -96,7 +100,7 @@ pub(crate) async fn product_suggestion_by_name(
     req: HttpRequest,
     name: web::Path<String>,
 ) -> Result<impl Responder> {
-    let _name = name;
+    drop(name);
 
     let name = UrlEncodedString::from_str(
         req.path()
@@ -118,13 +122,116 @@ pub(crate) async fn product_suggestion_by_name(
 /// Return all registered products
 #[utoipa::path(
     responses(
-        (status = OK, description = "All products founds", body = Vec<Product>),
+        (status = OK, description = "All products found", body = Vec<Product>),
         (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
     ),
 )]
-#[get("/products/")]
-pub(crate) async fn products(app: web::Data<App>) -> Result<impl Responder> {
+#[get("/products_registered/")]
+pub(crate) async fn products_registered(app: web::Data<App>) -> Result<impl Responder> {
     let all = Product::get_all(&app).await?;
 
     Ok(HttpResponse::Ok().json(all))
 }
+
+/// Return all products, which non-null amount in storage
+#[utoipa::path(
+    responses(
+        (status = OK, description = "All products found", body = Vec<Product>),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+)]
+#[get("/products_in_storage/")]
+pub(crate) async fn products_in_storage(app: web::Data<App>) -> Result<impl Responder> {
+    let all = Product::get_all(&app).await?;
+
+    let mut output_products = Vec::with_capacity(all.len());
+    for product in all {
+        let amount = ProductAmount::from_id(&app, product.id).await?;
+
+        if amount.is_some_and(|amount| amount.amount.value > 0) {
+            output_products.push(product);
+        }
+    }
+
+    Ok(HttpResponse::Ok().json(output_products))
+}
+
+/// Get Products by it's product parent id
+///
+/// This will also return all products below this product parent id
+#[utoipa::path(
+    responses(
+        (status = OK, description = "Products found from database", body = Vec<Product>),
+        (status = NOT_FOUND, description = "Product parent id not found in database"),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+    params(
+        ("id" = ProductParentId, description = "Product parent id" ),
+    )
+)]
+#[get("/product/by-product-parent-id-indirect/{id}")]
+pub(crate) async fn products_by_product_parent_id_indirect(
+    app: web::Data<App>,
+    id: web::Path<ProductParentIdStub>,
+) -> Result<impl Responder> {
+    let id = id.into_inner();
+
+    if let Some(parent) = ProductParent::from_id(&app, id.into()).await? {
+        async fn collect_products(app: &App, parent: ProductParent) -> Result<Vec<Product>> {
+            let mut all = Product::get_all(app)
+                .await?
+                .into_iter()
+                .filter(|prod| prod.parent.is_some_and(|val| val == parent.id))
+                .collect::<Vec<_>>();
+
+            if let Some(child) = ProductParent::get_all(app)
+                .await?
+                .into_iter()
+                .find(|pp| pp.parent.is_some_and(|id| id == parent.id))
+            {
+                all.extend(Box::pin(collect_products(app, child)).await?);
+            }
+
+            Ok(all)
+        }
+
+        let all = collect_products(&app, parent).await?;
+
+        Ok(HttpResponse::Ok().json(all))
+    } else {
+        Ok(HttpResponse::NotFound().finish())
+    }
+}
+
+/// Get Products by it's product parent id
+///
+/// This will only return products directly associated with this product parent id
+#[utoipa::path(
+    responses(
+        (status = OK, description = "Products found from database", body = Vec<Product>),
+        (status = NOT_FOUND, description = "Product parent id not found in database"),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+    params(
+        ("id" = ProductParentId, description = "Product parent id" ),
+    )
+)]
+#[get("/product/by-product-parent-id-direct/{id}")]
+pub(crate) async fn products_by_product_parent_id_direct(
+    app: web::Data<App>,
+    id: web::Path<ProductParentIdStub>,
+) -> Result<impl Responder> {
+    let id = id.into_inner();
+
+    if let Some(parent) = ProductParent::from_id(&app, id.into()).await? {
+        let all = Product::get_all(&app)
+            .await?
+            .into_iter()
+            .filter(|prod| prod.parent.is_some_and(|val| val == parent.id))
+            .collect::<Vec<_>>();
+
+        Ok(HttpResponse::Ok().json(all))
+    } else {
+        Ok(HttpResponse::NotFound().finish())
+    }
+}
diff --git a/crates/rocie-server/src/api/get/product_parent.rs b/crates/rocie-server/src/api/get/product_parent.rs
new file mode 100644
index 0000000..a62397c
--- /dev/null
+++ b/crates/rocie-server/src/api/get/product_parent.rs
@@ -0,0 +1,64 @@
+use actix_web::{HttpResponse, Responder, error::Result, get, web};
+
+use crate::{
+    app::App,
+    storage::sql::product_parent::{ProductParent, ProductParentId, ProductParentIdStub},
+};
+
+/// Return all registered product parents
+#[utoipa::path(
+    responses(
+        (status = OK, description = "All parents found", body = Vec<ProductParent>),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+)]
+#[get("/product_parents/")]
+pub(crate) async fn product_parents(app: web::Data<App>) -> Result<impl Responder> {
+    let all: Vec<ProductParent> = ProductParent::get_all(&app).await?;
+
+    Ok(HttpResponse::Ok().json(all))
+}
+
+/// Return all registered product parents, that have no parents themselves
+#[utoipa::path(
+    responses(
+        (status = OK, description = "All parents found", body = Vec<ProductParent>),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+)]
+#[get("/product_parents_toplevel/")]
+pub(crate) async fn product_parents_toplevel(app: web::Data<App>) -> Result<impl Responder> {
+    let all: Vec<ProductParent> = ProductParent::get_all(&app)
+        .await?
+        .into_iter()
+        .filter(|parent| parent.parent.is_none())
+        .collect();
+
+    Ok(HttpResponse::Ok().json(all))
+}
+
+/// Return all parents, that have this parent as parent
+#[utoipa::path(
+    responses(
+        (status = OK, description = "All parents found", body = Vec<ProductParent>),
+        (status = INTERNAL_SERVER_ERROR, description = "Server encountered error", body = String)
+    ),
+    params(
+        ("id" = ProductParentId, description = "Product parent id" ),
+    ),
+)]
+#[get("/product_parents_under/{id}")]
+pub(crate) async fn product_parents_under(
+    app: web::Data<App>,
+    id: web::Path<ProductParentIdStub>,
+) -> Result<impl Responder> {
+    let id = id.into_inner().into();
+
+    let all: Vec<_> = ProductParent::get_all(&app)
+        .await?
+        .into_iter()
+        .filter(|parent| parent.parent.is_some_and(|found| found == id))
+        .collect();
+
+    Ok(HttpResponse::Ok().json(all))
+}
diff --git a/crates/rocie-server/src/api/get/recipe.rs b/crates/rocie-server/src/api/get/recipe.rs
new file mode 100644
index 0000000..70bab39
--- /dev/null
+++ b/crates/rocie-server/src/api/get/recipe.rs
@@ -0,0 +1,66 @@
+use actix_web::{HttpResponse, Responder, error::Result, get, web};
+
+use crate::{
+    app::App,
+    storage::sql::recipe::{Recipe, RecipeId, RecipeIdStub},
+};
+
+/// Get an recipe by it's id.
+#[utoipa::path(
+    responses(
+        (
+            status = OK,
+            description = "Recipe found in database and fetched",
+            body = Recipe,
+        ),
+        (
+            status = NOT_FOUND,
+            description = "Recipe not found in database"
+        ),
+        (
+            status = INTERNAL_SERVER_ERROR,
+            description = "Server encountered error",
+            body = String
+        )
+    ),
+    params(
+        (
+            "id" = RecipeId,
+            description = "Recipe id"
+        ),
+    )
+)]
+#[get("/recipe/by-id/{id}")]
+pub(crate) async fn recipe_by_id(
+    app: web::Data<App>,
+    id: web::Path<RecipeIdStub>,
+) -> Result<impl Responder> {
+    let id = id.into_inner();
+
+    match Recipe::from_id(&app, id.into()).await? {
+        Some(recipe) => Ok(HttpResponse::Ok().json(recipe)),
+        None => Ok(HttpResponse::NotFound().finish()),
+    }
+}
+
+/// Get all added recipes
+#[utoipa::path(
+    responses(
+        (
+            status = OK,
+            description = "All recipes found in database and fetched",
+            body = Recipe,
+        ),
+        (
+            status = INTERNAL_SERVER_ERROR,
+            description = "Server encountered error",
+            body = String
+        )
+    ),
+)]
+#[get("/recipe/all")]
+pub(crate) async fn recipes(app: web::Data<App>) -> Result<impl Responder> {
+    let all = Recipe::get_all(&app).await?;
+
+    Ok(HttpResponse::Ok().json(all))
+}
diff --git a/crates/rocie-server/src/api/get/unit.rs b/crates/rocie-server/src/api/get/unit.rs
index 73aa626..caafaa3 100644
--- a/crates/rocie-server/src/api/get/unit.rs
+++ b/crates/rocie-server/src/api/get/unit.rs
@@ -1,6 +1,12 @@
-use actix_web::{get, web, HttpResponse, Responder, Result};
+use actix_web::{HttpResponse, Responder, Result, get, web};
 
-use crate::{app::App, storage::sql::unit::{Unit, UnitId, UnitIdStub}};
+use crate::{
+    app::App,
+    storage::sql::{
+        unit::{Unit, UnitId, UnitIdStub},
+        unit_property::{UnitPropertyId, UnitPropertyIdStub},
+    },
+};
 
 /// Return all registered units
 #[utoipa::path(
@@ -24,6 +30,42 @@ pub(crate) async fn units(app: web::Data<App>) -> Result<impl Responder> {
     Ok(HttpResponse::Ok().json(all))
 }
 
+/// Return all registered units for a specific unit property
+#[utoipa::path(
+    responses(
+        (
+            status = OK,
+            description = "All units founds",
+            body = Vec<Unit>
+        ),
+        (
+            status = INTERNAL_SERVER_ERROR,
+            description = "Server encountered error",
+            body = String
+        )
+    ),
+    params(
+        (
+            "id" = UnitPropertyId,
+            description = "Unit property id"
+        ),
+    )
+)]
+#[get("/units-by-property/{id}")]
+pub(crate) async fn units_by_property_id(
+    app: web::Data<App>,
+    id: web::Path<UnitPropertyIdStub>,
+) -> Result<impl Responder> {
+    let id = id.into_inner();
+    let all = Unit::get_all(&app)
+        .await?
+        .into_iter()
+        .filter(|unit| unit.unit_property == id.into())
+        .collect::<Vec<_>>();
+
+    Ok(HttpResponse::Ok().json(all))
+}
+
 /// Get Unit by id
 #[utoipa::path(
     responses(
diff --git a/crates/rocie-server/src/api/set/mod.rs b/crates/rocie-server/src/api/set/mod.rs
index 5dd0219..a6037b9 100644
--- a/crates/rocie-server/src/api/set/mod.rs
+++ b/crates/rocie-server/src/api/set/mod.rs
@@ -2,12 +2,16 @@ use actix_web::web;
 
 pub(crate) mod barcode;
 pub(crate) mod product;
+pub(crate) mod product_parent;
+pub(crate) mod recipe;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
 
 pub(crate) fn register_paths(cfg: &mut web::ServiceConfig) {
     cfg.service(product::register_product)
         .service(product::associate_barcode)
+        .service(product_parent::register_product_parent)
+        .service(recipe::add_recipe)
         .service(unit::register_unit)
         .service(unit_property::register_unit_property)
         .service(barcode::consume_barcode)
diff --git a/crates/rocie-server/src/api/set/product.rs b/crates/rocie-server/src/api/set/product.rs
index 7372d23..74a92d2 100644
--- a/crates/rocie-server/src/api/set/product.rs
+++ b/crates/rocie-server/src/api/set/product.rs
@@ -8,6 +8,7 @@ use crate::{
         barcode::Barcode,
         insert::Operations,
         product::{Product, ProductId, ProductIdStub},
+        product_parent::ProductParentId,
         unit::Unit,
         unit_property::UnitPropertyId,
     },
@@ -22,10 +23,12 @@ struct ProductStub {
     unit_property: UnitPropertyId,
 
     /// A description.
+    #[schema(nullable = false)]
     description: Option<String>,
 
     /// A parent of this product, otherwise the parent will be the root of the parent tree.
-    parent: Option<ProductId>,
+    #[schema(nullable = false)]
+    parent: Option<ProductParentId>,
 }
 
 /// Register a product
@@ -54,7 +57,7 @@ pub(crate) async fn register_product(
     let product = Product::register(
         product_stub.name.clone(),
         product_stub.description.clone(),
-        product_stub.parent,
+        product_stub.parent.into(),
         product_stub.unit_property,
         &mut ops,
     );
diff --git a/crates/rocie-server/src/api/set/product_parent.rs b/crates/rocie-server/src/api/set/product_parent.rs
new file mode 100644
index 0000000..f917207
--- /dev/null
+++ b/crates/rocie-server/src/api/set/product_parent.rs
@@ -0,0 +1,60 @@
+use actix_web::{HttpResponse, Responder, Result, post, web};
+use serde::Deserialize;
+use utoipa::ToSchema;
+
+use crate::{
+    app::App,
+    storage::sql::{
+        insert::Operations,
+        product_parent::{ProductParent, ProductParentId},
+    },
+};
+
+#[derive(Deserialize, ToSchema)]
+struct ProductParentStub {
+    /// The name of the product parent
+    name: String,
+
+    /// A description.
+    #[schema(nullable = false)]
+    description: Option<String>,
+
+    /// A parent of this product parent, otherwise the parent will be the root of the parent tree.
+    #[schema(nullable = false)]
+    parent: Option<ProductParentId>,
+}
+
+/// Register a product parent
+#[utoipa::path(
+    responses(
+        (
+            status = 200,
+            description = "Product parent successfully registered in database",
+            body = ProductParentId,
+        ),
+        (
+            status = INTERNAL_SERVER_ERROR,
+            description = "Server encountered error",
+            body = String,
+        )
+    ),
+    request_body = ProductParentStub,
+)]
+#[post("/product_parent/new")]
+pub(crate) async fn register_product_parent(
+    app: web::Data<App>,
+    product_stub: web::Json<ProductParentStub>,
+) -> Result<impl Responder> {
+    let mut ops = Operations::new("register product parent");
+
+    let product = ProductParent::register(
+        product_stub.name.clone(),
+        product_stub.description.clone(),
+        product_stub.parent.into(),
+        &mut ops,
+    );
+
+    ops.apply(&app).await?;
+
+    Ok(HttpResponse::Ok().json(product.id))
+}
diff --git a/crates/rocie-server/src/api/set/recipe.rs b/crates/rocie-server/src/api/set/recipe.rs
new file mode 100644
index 0000000..bb5be37
--- /dev/null
+++ b/crates/rocie-server/src/api/set/recipe.rs
@@ -0,0 +1,54 @@
+use std::path::PathBuf;
+
+use actix_web::{HttpResponse, Responder, error::Result, post, web};
+use serde::Deserialize;
+use utoipa::ToSchema;
+
+use crate::{
+    app::App,
+    storage::sql::{
+        insert::Operations,
+        recipe::{Recipe, RecipeId},
+    },
+};
+
+#[derive(Deserialize, ToSchema)]
+struct RecipeStub {
+    /// The path the recipe should have
+    #[schema(value_type = String)]
+    path: PathBuf,
+
+    /// The content of this recipe, in cooklang format
+    content: String,
+}
+
+/// Register a product parent
+#[utoipa::path(
+    responses(
+        (
+            status = 200,
+            description = "Product parent successfully registered in database",
+            body = RecipeId,
+        ),
+        (
+            status = INTERNAL_SERVER_ERROR,
+            description = "Server encountered error",
+            body = String,
+        )
+    ),
+    request_body = RecipeStub,
+)]
+#[post("/recipe/new")]
+pub(crate) async fn add_recipe(
+    app: web::Data<App>,
+    stub: web::Json<RecipeStub>,
+) -> Result<impl Responder> {
+    let stub = stub.into_inner();
+    let mut ops = Operations::new("add recipe parent");
+
+    let recipe = Recipe::new(stub.path, stub.content, &mut ops);
+
+    ops.apply(&app).await?;
+
+    Ok(HttpResponse::Ok().json(recipe.id))
+}
diff --git a/crates/rocie-server/src/api/set/unit.rs b/crates/rocie-server/src/api/set/unit.rs
index 5f39c8f..1671918 100644
--- a/crates/rocie-server/src/api/set/unit.rs
+++ b/crates/rocie-server/src/api/set/unit.rs
@@ -17,6 +17,8 @@ struct UnitStub {
     full_name_singular: String,
     short_name: String,
     unit_property: UnitPropertyId,
+
+    #[schema(nullable = false)]
     description: Option<String>,
 }
 
diff --git a/crates/rocie-server/src/api/set/unit_property.rs b/crates/rocie-server/src/api/set/unit_property.rs
index 9915e84..ca2960f 100644
--- a/crates/rocie-server/src/api/set/unit_property.rs
+++ b/crates/rocie-server/src/api/set/unit_property.rs
@@ -12,7 +12,11 @@ use crate::{
 
 #[derive(Deserialize, ToSchema)]
 struct UnitPropertyStub {
+    /// The name of the unit property.
     name: String,
+
+    /// An optional description of the unit property.
+    #[schema(nullable = false)]
     description: Option<String>,
 }
 
diff --git a/crates/rocie-server/src/main.rs b/crates/rocie-server/src/main.rs
index 2329b0b..dc5be0b 100644
--- a/crates/rocie-server/src/main.rs
+++ b/crates/rocie-server/src/main.rs
@@ -1,5 +1,10 @@
 use actix_cors::Cors;
-use actix_web::{App, HttpServer, middleware::Logger, web::Data};
+use actix_web::{
+    App, HttpServer,
+    cookie::{Key, SameSite},
+    middleware::Logger,
+    web::Data,
+};
 use clap::Parser;
 use utoipa::OpenApi;
 
@@ -10,6 +15,9 @@ mod app;
 mod cli;
 mod storage;
 
+use actix_identity::IdentityMiddleware;
+use actix_session::{SessionMiddleware, storage::CookieSessionStore};
+
 #[actix_web::main]
 #[expect(
     clippy::needless_for_each,
@@ -22,14 +30,25 @@ async fn main() -> Result<(), std::io::Error> {
             api::get::product::product_by_id,
             api::get::product::product_by_name,
             api::get::product::product_suggestion_by_name,
-            api::get::product::products,
+            api::get::product::products_registered,
+            api::get::product::products_in_storage,
+            api::get::product::products_by_product_parent_id_indirect,
+            api::get::product::products_by_product_parent_id_direct,
+            api::get::product_parent::product_parents,
+            api::get::product_parent::product_parents_toplevel,
+            api::get::product_parent::product_parents_under,
+            api::get::recipe::recipe_by_id,
+            api::get::recipe::recipes,
             api::get::unit::units,
+            api::get::unit::units_by_property_id,
             api::get::unit::unit_by_id,
             api::get::unit_property::unit_property_by_id,
             api::get::unit_property::unit_properties,
             api::get::inventory::amount_by_id,
             api::set::product::register_product,
             api::set::product::associate_barcode,
+            api::set::product_parent::register_product_parent,
+            api::set::recipe::add_recipe,
             api::set::unit::register_unit,
             api::set::unit_property::register_unit_property,
             api::set::barcode::buy_barcode,
@@ -45,6 +64,11 @@ async fn main() -> Result<(), std::io::Error> {
 
     env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
 
+    // When using `Key::generate()` it is important to initialize outside of the
+    // `HttpServer::new` closure. When deployed the secret key should be read from a
+    // configuration file or environment variables.
+    let secret_key = Key::generate();
+
     let args = CliArgs::parse();
 
     match args.command {
@@ -67,6 +91,18 @@ async fn main() -> Result<(), std::io::Error> {
                     .wrap(Logger::new(
                         r#"%a "%r" -> %s %b ("%{Referer}i" "%{User-Agent}i" %T s)"#,
                     ))
+                    // Install the identity framework before middle-ware (as actix is filo).
+                    .wrap(IdentityMiddleware::default())
+                    .wrap(
+                        SessionMiddleware::builder(
+                            CookieSessionStore::default(),
+                            secret_key.clone(),
+                        )
+                        .cookie_secure(true)
+                        .cookie_http_only(true)
+                        .cookie_same_site(SameSite::Strict)
+                        .build(),
+                    )
                     .app_data(Data::clone(&data))
                     .configure(api::get::register_paths)
                     .configure(api::set::register_paths)
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 7f08738..664f40f 100644
--- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql
+++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql
@@ -22,6 +22,8 @@ CREATE TABLE parents (
     parent                      TEXT                             DEFAULT NULL CHECK (
                                                                             id IS NOT parent
                                                                         ),
+    name                        TEXT UNIQUE NOT NULL,
+    description                 TEXT,
     FOREIGN KEY(parent) REFERENCES parents(id)
 ) STRICT;
 
@@ -97,7 +99,7 @@ END;
 
 CREATE TABLE units (
     id                          TEXT UNIQUE NOT NULL PRIMARY KEY,
-    unit_property               TEXT UNIQUE NOT NULL,
+    unit_property               TEXT NOT NULL,
     full_name_singular          TEXT UNIQUE NOT NULL,
     full_name_plural            TEXT UNIQUE NOT NULL,
     short_name                  TEXT UNIQUE NOT NULL,
@@ -111,6 +113,12 @@ CREATE TABLE unit_properties (
     description                 TEXT
 ) STRICT;
 
+CREATE TABLE recipies (
+    id                          TEXT UNIQUE NOT NULL PRIMARY KEY,
+    path                        TEXT UNIQUE NOT NULL,
+    content                     TEXT        NOT NULL
+) STRICT;
+
 -- Encodes unit conversions:
 -- {factor} {from_unit} = 1 {to_unit}
 -- E.g.: 1000 g = 1 kg
diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs
index 62047b8..1fb54b0 100644
--- a/crates/rocie-server/src/storage/sql/get/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/mod.rs
@@ -1,5 +1,7 @@
 pub(crate) mod product;
+pub(crate) mod product_parent;
 pub(crate) mod product_amount;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
 pub(crate) mod barcode;
+pub(crate) mod recipe;
diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs
index 0df51d8..915da81 100644
--- a/crates/rocie-server/src/storage/sql/get/product/mod.rs
+++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs
@@ -3,6 +3,7 @@ use crate::{
     storage::sql::{
         barcode::{Barcode, BarcodeId},
         product::{Product, ProductId},
+        product_parent::ProductParentId,
         unit::{UnitAmount, UnitId},
         unit_property::UnitPropertyId,
     },
@@ -10,11 +11,46 @@ use crate::{
 
 use sqlx::query;
 
+macro_rules! product_from_record {
+    ($app:ident, $record:ident) => {{
+        let barcodes = query!(
+            "
+            SELECT id, amount, unit
+            FROM barcodes
+            WHERE product_id = ?
+                    ",
+            $record.id,
+        )
+        .fetch_all(&$app.db)
+        .await?;
+
+        Self {
+            id: ProductId::from_db(&$record.id),
+            unit_property: UnitPropertyId::from_db(&$record.unit_property),
+            name: $record.name,
+            description: $record.description,
+            parent: $record
+                .parent
+                .map(|parent| ProductParentId::from_db(&parent)),
+            associated_bar_codes: barcodes
+                .into_iter()
+                .map(|record| Barcode {
+                    id: BarcodeId::from_db(record.id),
+                    amount: UnitAmount {
+                        value: u32::try_from(record.amount).expect("Should be strictly positve"),
+                        unit: UnitId::from_db(&record.unit),
+                    },
+                })
+                .collect(),
+        }
+    }};
+}
+
 impl Product {
     pub(crate) async fn from_id(app: &App, id: ProductId) -> Result<Option<Self>, from_id::Error> {
         let record = query!(
             "
-        SELECT name, unit_property, description, parent
+        SELECT name, id, unit_property, description, parent
         FROM products
         WHERE id = ?
 ",
@@ -24,13 +60,7 @@ impl Product {
         .await?;
 
         if let Some(record) = record {
-            Ok(Some(Self {
-                id,
-                unit_property: UnitPropertyId::from_db(&record.unit_property),
-                name: record.name,
-                description: record.description,
-                associated_bar_codes: vec![], // todo
-            }))
+            Ok(Some(product_from_record!(app, record)))
         } else {
             Ok(None)
         }
@@ -49,13 +79,7 @@ impl Product {
         .await?;
 
         if let Some(record) = record {
-            Ok(Some(Self {
-                id: ProductId::from_db(&record.id),
-                unit_property: UnitPropertyId::from_db(&record.unit_property),
-                name,
-                description: record.description,
-                associated_bar_codes: vec![], // todo
-            }))
+            Ok(Some(product_from_record!(app, record)))
         } else {
             Ok(None)
         }
@@ -73,34 +97,9 @@ impl Product {
 
         let mut all = Vec::with_capacity(records.len());
         for record in records {
-            let barcodes = query!(
-                "
-                SELECT id, amount, unit
-                FROM barcodes
-                WHERE product_id = ?
-",
-                record.id,
-            )
-            .fetch_all(&app.db)
-            .await?;
-
-            all.push(Self {
-                id: ProductId::from_db(&record.id),
-                unit_property: UnitPropertyId::from_db(&record.unit_property),
-                name: record.name,
-                description: record.description,
-                associated_bar_codes: barcodes
-                    .into_iter()
-                    .map(|record| Barcode {
-                        id: BarcodeId::from_db(record.id),
-                        amount: UnitAmount {
-                            value: u32::try_from(record.amount)
-                                .expect("Should be strictly positve"),
-                            unit: UnitId::from_db(&record.unit),
-                        },
-                    })
-                    .collect(),
-            });
+            let product = product_from_record!(app, record);
+
+            all.push(product);
         }
 
         Ok(all)
diff --git a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs
new file mode 100644
index 0000000..5b85b62
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs
@@ -0,0 +1,87 @@
+use crate::{
+    app::App,
+    storage::sql::product_parent::{ProductParent, ProductParentId},
+};
+
+use sqlx::query;
+
+impl ProductParent {
+    pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> {
+        let records = query!(
+            "
+        SELECT id, parent, name, description
+        FROM parents
+"
+        )
+        .fetch_all(&app.db)
+        .await?;
+
+        let mut all = Vec::with_capacity(records.len());
+        for record in records {
+            let parent = ProductParent {
+                id: ProductParentId::from_db(&record.id),
+                parent: record
+                    .parent
+                    .map(|parent| ProductParentId::from_db(&parent)),
+                name: record.name,
+                description: record.description,
+            };
+
+            all.push(parent);
+        }
+
+        Ok(all)
+    }
+
+    pub(crate) async fn from_id(
+        app: &App,
+        id: ProductParentId,
+    ) -> Result<Option<Self>, from_id::Error> {
+        let record = query!(
+            "
+        SELECT parent, name, description
+        FROM parents
+        WHERE id = ?
+",
+            id
+        )
+        .fetch_optional(&app.db)
+        .await?;
+
+        match record {
+            Some(record) => Ok(Some(ProductParent {
+                id,
+                parent: record
+                    .parent
+                    .map(|parent| ProductParentId::from_db(&parent)),
+                name: record.name,
+                description: record.description,
+            })),
+            None => Ok(None),
+        }
+    }
+}
+
+pub(crate) mod from_id {
+    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 {}
+}
+
+pub(crate) mod get_all {
+    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/recipe/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs
new file mode 100644
index 0000000..9d6dc79
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs
@@ -0,0 +1,78 @@
+use crate::{
+    app::App,
+    storage::sql::recipe::{Recipe, RecipeId},
+};
+
+use sqlx::query;
+
+impl Recipe {
+    pub(crate) async fn from_id(app: &App, id: RecipeId) -> Result<Option<Self>, from_id::Error> {
+        let record = query!(
+            "
+        SELECT content, path
+        FROM recipies
+        WHERE id = ?
+",
+            id
+        )
+        .fetch_optional(&app.db)
+        .await?;
+
+        if let Some(record) = record {
+            Ok(Some(Self {
+                id,
+                path: record
+                    .path
+                    .parse()
+                    .expect("Was a path before, should still be one"),
+                content: record.content,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
+
+    pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> {
+        let records = query!(
+            "
+        SELECT id, content, path
+        FROM recipies
+",
+        )
+        .fetch_all(&app.db)
+        .await?;
+
+        Ok(records
+            .into_iter()
+            .map(|record| Self {
+                id: RecipeId::from_db(&record.id),
+                path: record.path.parse().expect("Is still valid"),
+                content: record.content,
+            })
+            .collect())
+    }
+}
+
+pub(crate) mod from_id {
+    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 {}
+}
+
+pub(crate) mod get_all {
+    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/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs
index 3b2d702..8a15385 100644
--- a/crates/rocie-server/src/storage/sql/insert/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/mod.rs
@@ -9,8 +9,10 @@ use sqlx::{SqliteConnection, query};
 
 pub(crate) mod barcode;
 pub(crate) mod product;
+pub(crate) mod product_parent;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
+pub(crate) mod recipe;
 
 pub(crate) trait Transactionable:
     Sized + std::fmt::Debug + Serialize + DeserializeOwned
diff --git a/crates/rocie-server/src/storage/sql/insert/product/mod.rs b/crates/rocie-server/src/storage/sql/insert/product/mod.rs
index d762e9b..455eb4f 100644
--- a/crates/rocie-server/src/storage/sql/insert/product/mod.rs
+++ b/crates/rocie-server/src/storage/sql/insert/product/mod.rs
@@ -6,6 +6,7 @@ use crate::storage::sql::{
     barcode::Barcode,
     insert::{Operations, Transactionable},
     product::{Product, ProductId},
+    product_parent::ProductParentId,
     unit_property::UnitPropertyId,
 };
 
@@ -15,7 +16,7 @@ pub(crate) enum Operation {
         id: ProductId,
         name: String,
         description: Option<String>,
-        parent: Option<ProductId>,
+        parent: Option<ProductParentId>,
         unit_property: UnitPropertyId,
     },
     AssociateBarcode {
@@ -138,7 +139,7 @@ impl Product {
     pub(crate) fn register(
         name: String,
         description: Option<String>,
-        parent: Option<ProductId>,
+        parent: Option<ProductParentId>,
         unit_property: UnitPropertyId,
         ops: &mut Operations<Operation>,
     ) -> Self {
@@ -157,6 +158,7 @@ impl Product {
             name,
             description,
             unit_property,
+            parent,
             associated_bar_codes: vec![],
         }
     }
diff --git a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs
new file mode 100644
index 0000000..644778f
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs
@@ -0,0 +1,113 @@
+use serde::{Deserialize, Serialize};
+use sqlx::query;
+use uuid::Uuid;
+
+use crate::storage::sql::{
+    insert::{Operations, Transactionable},
+    product_parent::{ProductParent, ProductParentId},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) enum Operation {
+    RegisterProductParent {
+        id: ProductParentId,
+        name: String,
+        description: Option<String>,
+        parent: Option<ProductParentId>,
+    },
+}
+
+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::RegisterProductParent {
+                id,
+                name,
+                description,
+                parent,
+            } => {
+                query!(
+                    "
+                    INSERT INTO parents (id, name, description, parent)
+                    VALUES (?,?,?,?)
+",
+                    id,
+                    name,
+                    description,
+                    parent
+                )
+                .execute(txn)
+                .await?;
+            }
+        }
+        Ok(())
+    }
+
+    async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
+        match self {
+            Operation::RegisterProductParent {
+                id,
+                name,
+                description,
+                parent,
+            } => {
+                query!(
+                    "
+                    DELETE FROM products
+                    WHERE id = ? AND name = ? AND description = ? AND parent = ?;
+",
+                    id,
+                    name,
+                    description,
+                    parent,
+                )
+                .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 ProductParent {
+    pub(crate) fn register(
+        name: String,
+        description: Option<String>,
+        parent: Option<ProductParentId>,
+        ops: &mut Operations<Operation>,
+    ) -> Self {
+        let id = ProductParentId::from(Uuid::new_v4());
+
+        ops.push(Operation::RegisterProductParent {
+            id,
+            name: name.clone(),
+            description: description.clone(),
+            parent,
+        });
+
+        Self {
+            id,
+            name,
+            description,
+            parent,
+        }
+    }
+}
diff --git a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs
new file mode 100644
index 0000000..b223bfe
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs
@@ -0,0 +1,95 @@
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use sqlx::query;
+use uuid::Uuid;
+
+use crate::storage::sql::{
+    insert::{Operations, Transactionable},
+    recipe::{Recipe, RecipeId},
+};
+
+#[derive(Debug, Deserialize, Serialize)]
+pub(crate) enum Operation {
+    New {
+        id: RecipeId,
+        path: PathBuf,
+        content: String,
+    },
+}
+
+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::New { id, path, content } => {
+                let path = path.display().to_string();
+
+                query!(
+                    "
+                    INSERT INTO recipies (id, path, content)
+                    VALUES (?, ?, ?)
+",
+                    id,
+                    path,
+                    content,
+                )
+                .execute(txn)
+                .await?;
+            }
+        }
+        Ok(())
+    }
+
+    async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> {
+        match self {
+            Operation::New { id, path, content } => {
+                let path = path.display().to_string();
+
+                query!(
+                    "
+                    DELETE FROM recipies
+                    WHERE id = ? AND path = ? AND content = ?
+",
+                    id,
+                    path,
+                    content
+                )
+                .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 Recipe {
+    pub(crate) fn new(path: PathBuf, content: String, ops: &mut Operations<Operation>) -> Self {
+        let id = RecipeId::from(Uuid::new_v4());
+
+        ops.push(Operation::New {
+            id,
+            path: path.clone(),
+            content: content.clone(),
+        });
+
+        Self { id, path, content }
+    }
+}
diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs
index a44fbad..dd46eab 100644
--- a/crates/rocie-server/src/storage/sql/mod.rs
+++ b/crates/rocie-server/src/storage/sql/mod.rs
@@ -5,14 +5,26 @@ pub(crate) mod insert;
 pub(crate) mod barcode;
 pub(crate) mod product;
 pub(crate) mod product_amount;
+pub(crate) mod product_parent;
+pub(crate) mod recipe;
 pub(crate) mod unit;
 pub(crate) mod unit_property;
 
 macro_rules! mk_id {
     ($name:ident and $stub_name:ident) => {
-        mk_id!($name and $stub_name with uuid::Uuid, "uuid::Uuid");
+        mk_id!(
+            $name and $stub_name,
+            with uuid::Uuid, "uuid::Uuid",
+            to_string {|val: &uuid::Uuid| val.to_string()},
+            copy Copy
+        );
     };
-    ($name:ident and $stub_name:ident with $inner:path, $inner_string:literal $($args:meta)*) => {
+    (
+        $name:ident and $stub_name:ident,
+        with $inner:path $(=> $($args:meta)* )?, $inner_string:literal,
+        to_string $to_string:expr,
+        $(copy $copy:path)?
+    ) => {
         #[derive(
             serde::Deserialize,
             serde::Serialize,
@@ -20,17 +32,28 @@ macro_rules! mk_id {
             Default,
             utoipa::ToSchema,
             Clone,
-            Copy,
             PartialEq,
             Eq,
             PartialOrd,
             Ord,
+            $($copy,)?
         )]
         pub(crate) struct $name {
+            $(
+                $(
+                    #[$args]
+                )*
+            )?
             value: $inner,
         }
 
-        #[derive(Deserialize, Serialize, Debug, Clone, Copy)]
+        #[derive(
+            Deserialize,
+            Serialize,
+            Debug,
+            Clone,
+            $($copy,)?
+        )]
         #[serde(from = $inner_string)]
         pub(crate) struct $stub_name {
             value: $inner,
@@ -55,7 +78,7 @@ macro_rules! mk_id {
 
         impl std::fmt::Display for $name {
             fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-                write!(f, "{}", self.value)
+                write!(f, "{}", $to_string(&self.value))
             }
         }
 
@@ -83,7 +106,7 @@ macro_rules! mk_id {
                 &self,
                 buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
             ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
-                let inner = self.value.to_string();
+                let inner = $to_string(&self.value);
                 sqlx::Encode::<DB>::encode_by_ref(&inner, buf)
             }
         }
diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs
index 1c5a7d8..8d2c951 100644
--- a/crates/rocie-server/src/storage/sql/product.rs
+++ b/crates/rocie-server/src/storage/sql/product.rs
@@ -1,7 +1,7 @@
 use serde::{Deserialize, Serialize};
 use utoipa::ToSchema;
 
-use crate::storage::sql::{barcode::Barcode, mk_id, unit_property::UnitPropertyId};
+use crate::storage::sql::{barcode::Barcode, mk_id, product_parent::ProductParentId, unit_property::UnitPropertyId};
 
 /// The base of rocie.
 ///
@@ -12,6 +12,12 @@ pub(crate) struct Product {
     /// The id of the product.
     pub(crate) id: ProductId,
 
+    /// The parent this product has.
+    ///
+    /// This is effectively it's anchor in the product DAG.
+    #[schema(nullable = false)]
+    pub(crate) parent: Option<ProductParentId>,
+
     /// The property this product is measured in.
     ///
     /// (This is probably always either Mass, Volume or Quantity).
@@ -23,6 +29,7 @@ pub(crate) struct Product {
     pub(crate) name: String,
 
     /// An optional description of this product.
+    #[schema(nullable = false)]
     pub(super) description: Option<String>,
 
     /// Which barcodes are associated with this product.
diff --git a/crates/rocie-server/src/storage/sql/product_parent.rs b/crates/rocie-server/src/storage/sql/product_parent.rs
new file mode 100644
index 0000000..f689024
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/product_parent.rs
@@ -0,0 +1,31 @@
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::storage::sql::mk_id;
+
+/// The grouping system for products.
+///
+/// Every Product can have a related parent, and every parent can have a parent themselves.
+/// As such, the products list constructs a DAG.
+#[derive(Clone, ToSchema, Serialize, Deserialize)]
+pub(crate) struct ProductParent {
+    /// The id of the product parent.
+    pub(crate) id: ProductParentId,
+
+    /// The optional id of the parent of this product parent.
+    ///
+    /// This must not form a cycle.
+    #[schema(nullable = false)]
+    pub(crate) parent: Option<ProductParentId>,
+
+    /// The name of the product parent.
+    ///
+    /// This should be globally unique, to make searching easier for the user.
+    pub(crate) name: String,
+
+    /// An optional description of this product parent.
+    #[schema(nullable = false)]
+    pub(super) description: Option<String>,
+}
+
+mk_id!(ProductParentId and ProductParentIdStub);
diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs
new file mode 100644
index 0000000..835d98b
--- /dev/null
+++ b/crates/rocie-server/src/storage/sql/recipe.rs
@@ -0,0 +1,18 @@
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::storage::sql::mk_id;
+
+#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)]
+pub(crate) struct Recipe {
+    pub(crate) id: RecipeId,
+
+    #[schema(value_type = String)]
+    pub(crate) path: PathBuf,
+
+    pub(crate) content: String,
+}
+
+mk_id!(RecipeId and RecipeIdStub);
diff --git a/crates/rocie-server/src/storage/sql/unit.rs b/crates/rocie-server/src/storage/sql/unit.rs
index d16e783..8bbfe60 100644
--- a/crates/rocie-server/src/storage/sql/unit.rs
+++ b/crates/rocie-server/src/storage/sql/unit.rs
@@ -29,6 +29,7 @@ pub(crate) struct Unit {
     pub(crate) short_name: String,
 
     /// Description of this unit.
+    #[schema(nullable = false)]
     pub(crate) description: Option<String>,
 
     /// Which property is described by this unit.
diff --git a/crates/rocie-server/src/storage/sql/unit_property.rs b/crates/rocie-server/src/storage/sql/unit_property.rs
index 9da2d2e..adb4767 100644
--- a/crates/rocie-server/src/storage/sql/unit_property.rs
+++ b/crates/rocie-server/src/storage/sql/unit_property.rs
@@ -17,6 +17,7 @@ pub(crate) struct UnitProperty {
     pub(crate) units: Vec<UnitId>,
 
     /// An description of this property.
+    #[schema(nullable = false)]
     pub(crate) description: Option<String>,
 }