Initial HerbAPI implementation

Rust/Axum REST API (herbapi-api) with PostgreSQL, S3/Garage, OIDC auth.
Dioxus 0.7 WASM frontend (herbapi-ui) with sidebar layout and botanical reference style.
9 SQL migrations covering families, species, cultivars, suppliers, companions, images, users, API tokens.
This commit is contained in:
2026-03-14 00:02:29 +01:00
commit 484979ad53
56 changed files with 12792 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CompanionRelationship, CreateCompanion};
pub async fn list_for_species(pool: &PgPool, species_id: Uuid) -> Result<Vec<CompanionRelationship>> {
sqlx::query_as::<_, CompanionRelationship>(
"SELECT * FROM companion_relationships
WHERE species_a_id = $1 OR species_b_id = $1
ORDER BY relationship, created_at"
)
.bind(species_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn create(pool: &PgPool, req: &CreateCompanion) -> Result<CompanionRelationship> {
let id = Uuid::now_v7();
// Enforce ordering: species_a_id < species_b_id
let (a, b) = if req.species_a_id < req.species_b_id {
(req.species_a_id, req.species_b_id)
} else {
(req.species_b_id, req.species_a_id)
};
sqlx::query_as::<_, CompanionRelationship>(
"INSERT INTO companion_relationships (id, species_a_id, species_b_id, relationship, mechanism, source_url)
VALUES ($1, $2, $3, $4::companion_type, $5, $6) RETURNING *"
)
.bind(id).bind(a).bind(b).bind(&req.relationship)
.bind(&req.mechanism).bind(&req.source_url)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM companion_relationships WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Relationship not found: {id}")));
}
Ok(())
}
+235
View File
@@ -0,0 +1,235 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateCultivar, Cultivar, PaginatedResponse};
#[derive(Debug, serde::Deserialize)]
pub struct CultivarListParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
pub species: Option<String>,
}
impl CultivarListParams {
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
}
pub async fn list(pool: &PgPool, params: &CultivarListParams) -> Result<PaginatedResponse<Cultivar>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = match (&params.species, &params.search) {
(Some(species_slug), Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
@@ to_tsquery('english', $2)
ORDER BY c.name LIMIT $3 OFFSET $4"
)
.bind(species_slug).bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
@@ to_tsquery('english', $2)"
)
.bind(species_slug).bind(&tsquery)
.fetch_one(pool).await?;
(rows, count)
}
(Some(species_slug), None) => {
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1 ORDER BY c.name LIMIT $2 OFFSET $3"
)
.bind(species_slug).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id WHERE s.slug = $1"
)
.bind(species_slug).fetch_one(pool).await?;
(rows, count)
}
(None, Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT * FROM cultivars
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY name LIMIT $2 OFFSET $3"
)
.bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery).fetch_one(pool).await?;
(rows, count)
}
(None, None) => {
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT * FROM cultivars ORDER BY name LIMIT $1 OFFSET $2"
)
.bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cultivars")
.fetch_one(pool).await?;
(rows, count)
}
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Cultivar> {
sqlx::query_as::<_, Cultivar>("SELECT * FROM cultivars WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result<Cultivar> {
let id = Uuid::now_v7();
// Get species slug for cultivar slug prefix
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
.bind(req.species_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
sqlx::query_as::<_, Cultivar>(
"INSERT INTO cultivars (id, slug, species_id, name, name_en, name_de, name_scientific,
description, is_organic, perennial, growing_time_days, planting_depth_cm, row_spacing_cm,
plant_spacing_cm, days_to_germination, germination_temp_c, light_requirement,
stratification_required, stratification_days, scarification_required,
seed_viability_years, storage_temp_c, storage_humidity, storage_notes,
min_temp, max_temp, humidity, light, frost_tolerance,
min_light_hours_day, optimal_light_hours_day, greenhouse_min_temp_c,
indoor_season_extension_weeks, ventilation_requirement, heating_required,
indoor_sowing_months, direct_sowing_months, transplanting_months, glasshouse_months,
harvesting_months, succession_planting_days, planting_notes,
propagation_methods, cutting_season, rootstock_species_id,
years_to_first_harvest, productive_lifespan_years,
expected_yield_kg_per_m2, yield_unit, expected_yield_value,
harvest_window_days, storage_method, shelf_life_days, cold_storage_days,
pollination_group, self_fertile, rootstock_compatibility,
wikidata_qid, gbif_id, pfaf_url, source_urls)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29::frost_tolerance,$30,$31,$32,$33,$34,$35,
$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53,$54,
$55,$56,$57,$58,$59,$60,$61)
RETURNING *"
)
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
.bind(&req.storage_humidity).bind(&req.storage_notes)
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
.bind(&req.frost_tolerance)
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
.bind(req.harvest_window_days).bind(&req.storage_method)
.bind(req.shelf_life_days).bind(req.cold_storage_days)
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateCultivar) -> Result<Cultivar> {
let _existing = sqlx::query("SELECT id FROM cultivars WHERE id = $1")
.bind(id).fetch_optional(pool).await?
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {id}")))?;
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
.bind(req.species_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
sqlx::query_as::<_, Cultivar>(
"UPDATE cultivars SET slug=$2, species_id=$3, name=$4, name_en=$5, name_de=$6,
name_scientific=$7, description=$8, is_organic=$9, perennial=$10,
growing_time_days=$11, planting_depth_cm=$12, row_spacing_cm=$13, plant_spacing_cm=$14,
days_to_germination=$15, germination_temp_c=$16, light_requirement=$17,
stratification_required=$18, stratification_days=$19, scarification_required=$20,
seed_viability_years=$21, storage_temp_c=$22, storage_humidity=$23, storage_notes=$24,
min_temp=$25, max_temp=$26, humidity=$27, light=$28, frost_tolerance=$29::frost_tolerance,
min_light_hours_day=$30, optimal_light_hours_day=$31, greenhouse_min_temp_c=$32,
indoor_season_extension_weeks=$33, ventilation_requirement=$34, heating_required=$35,
indoor_sowing_months=$36, direct_sowing_months=$37, transplanting_months=$38,
glasshouse_months=$39, harvesting_months=$40, succession_planting_days=$41,
planting_notes=$42, propagation_methods=$43, cutting_season=$44,
rootstock_species_id=$45, years_to_first_harvest=$46, productive_lifespan_years=$47,
expected_yield_kg_per_m2=$48, yield_unit=$49, expected_yield_value=$50,
harvest_window_days=$51, storage_method=$52, shelf_life_days=$53, cold_storage_days=$54,
pollination_group=$55, self_fertile=$56, rootstock_compatibility=$57,
wikidata_qid=$58, gbif_id=$59, pfaf_url=$60, source_urls=$61, updated_at=NOW()
WHERE id=$1 RETURNING *"
)
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
.bind(&req.storage_humidity).bind(&req.storage_notes)
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
.bind(&req.frost_tolerance)
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
.bind(req.harvest_window_days).bind(&req.storage_method)
.bind(req.shelf_life_days).bind(req.cold_storage_days)
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM cultivars WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Cultivar not found: {id}")));
}
Ok(())
}
+126
View File
@@ -0,0 +1,126 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateFamily, Family, PaginatedResponse, PaginationParams, UpdateFamily};
pub async fn list(pool: &PgPool, params: &PaginationParams) -> Result<PaginatedResponse<Family>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = if let Some(ref search) = params.search {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Family>(
"SELECT * FROM families
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
@@ to_tsquery('english', $1)
ORDER BY name_scientific LIMIT $2 OFFSET $3"
)
.bind(&tsquery)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM families
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery)
.fetch_one(pool)
.await?;
(rows, count)
} else {
let rows = sqlx::query_as::<_, Family>(
"SELECT * FROM families ORDER BY name_scientific LIMIT $1 OFFSET $2"
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM families")
.fetch_one(pool)
.await?;
(rows, count)
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Family> {
sqlx::query_as::<_, Family>("SELECT * FROM families WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Family not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateFamily) -> Result<Family> {
let id = Uuid::now_v7();
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Family>(
"INSERT INTO families (id, slug, name_scientific, name_en, name_de, description)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"
)
.bind(id)
.bind(&slug)
.bind(&req.name_scientific)
.bind(&req.name_en)
.bind(&req.name_de)
.bind(&req.description)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &UpdateFamily) -> Result<Family> {
let existing = sqlx::query_as::<_, Family>("SELECT * FROM families WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Family not found: {id}")))?;
let name_sci = req.name_scientific.as_deref().unwrap_or(&existing.name_scientific);
let new_slug = if req.name_scientific.is_some() {
slug::slugify(name_sci)
} else {
existing.slug.clone()
};
sqlx::query_as::<_, Family>(
"UPDATE families SET slug = $2, name_scientific = $3,
name_en = COALESCE($4, name_en), name_de = COALESCE($5, name_de),
description = COALESCE($6, description), updated_at = NOW()
WHERE id = $1 RETURNING *"
)
.bind(id)
.bind(&new_slug)
.bind(name_sci)
.bind(&req.name_en)
.bind(&req.name_de)
.bind(&req.description)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM families WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Family not found: {id}")));
}
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::Image;
pub async fn list_for_entity(pool: &PgPool, entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>> {
sqlx::query_as::<_, Image>(
"SELECT * FROM images WHERE entity_type = $1 AND entity_id = $2 ORDER BY is_primary DESC, created_at"
)
.bind(entity_type).bind(entity_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn create(
pool: &PgPool,
entity_type: &str,
entity_id: Uuid,
s3_key: &str,
caption: Option<&str>,
source_url: Option<&str>,
license: Option<&str>,
is_primary: bool,
) -> Result<Image> {
let id = Uuid::now_v7();
sqlx::query_as::<_, Image>(
"INSERT INTO images (id, entity_type, entity_id, s3_key, caption, source_url, license, is_primary)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(entity_type).bind(entity_id).bind(s3_key)
.bind(caption).bind(source_url).bind(license).bind(is_primary)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Image> {
sqlx::query_as::<_, Image>("DELETE FROM images WHERE id = $1 RETURNING *")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Image not found: {id}")))
}
+9
View File
@@ -0,0 +1,9 @@
pub mod companions;
pub mod cultivars;
pub mod families;
pub mod images;
pub mod models;
pub mod s3;
pub mod species;
pub mod suppliers;
pub mod users;
+427
View File
@@ -0,0 +1,427 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
// --- Pagination ---
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
}
impl PaginationParams {
pub fn limit(&self) -> i64 {
self.per_page.unwrap_or(25).min(100)
}
pub fn offset(&self) -> i64 {
(self.page.unwrap_or(1) - 1).max(0) * self.limit()
}
}
#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T: Serialize> {
pub data: Vec<T>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
// --- Families ---
#[derive(Debug, FromRow, Serialize)]
pub struct Family {
pub id: Uuid,
pub slug: String,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateFamily {
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateFamily {
pub name_scientific: Option<String>,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
}
// --- Species ---
#[derive(Debug, FromRow, Serialize)]
pub struct Species {
pub id: Uuid,
pub slug: String,
pub family_id: Uuid,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub soil_moisture: Option<String>,
pub drainage_requirement: Option<String>,
pub organic_matter_pct: Option<f64>,
pub nitrogen_ppm: Option<i32>,
pub phosphorus_ppm: Option<i32>,
pub potassium_ppm: Option<i32>,
pub boron_ppm: Option<f64>,
pub calcium_ppm: Option<i32>,
pub copper_ppm: Option<f64>,
pub iron_ppm: Option<f64>,
pub magnesium_ppm: Option<i32>,
pub manganese_ppm: Option<f64>,
pub molybdenum_ppm: Option<f64>,
pub sulfur_ppm: Option<i32>,
pub zinc_ppm: Option<f64>,
pub ph_min: Option<f64>,
pub ph_max: Option<f64>,
pub soil_texture_preference: Option<Vec<String>>,
pub hardiness_zone_usda: Option<String>,
pub hardiness_zone_at: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub drought_tolerance: Option<String>,
pub water_requirement_mm_week: Option<f64>,
pub waterlogging_tolerance: Option<bool>,
pub salt_tolerance: Option<String>,
pub edibility_rating: Option<i16>,
pub food_uses: Option<String>,
pub medicinal_uses: Option<String>,
pub other_uses: Option<String>,
pub native_range: Option<String>,
pub invasiveness: Option<String>,
pub pollination_type: Option<String>,
pub plant_layer: Option<String>,
pub nitrogen_fixer: Option<bool>,
pub dynamic_accumulator: Option<bool>,
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
pub attracts_pollinators: Option<bool>,
pub attracts_beneficial_insects: Option<bool>,
pub wildlife_value: Option<String>,
pub mulch_plant: Option<bool>,
pub ground_cover_quality: Option<String>,
pub allelopathic: Option<bool>,
pub guild_role: Option<Vec<String>>,
pub succession_stage: Option<String>,
pub heavy_metal_tolerance: Option<bool>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub eppo_code: Option<String>,
pub pfaf_url: Option<String>,
pub primary_image_key: Option<String>,
pub source_urls: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateSpecies {
pub family_id: Uuid,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub soil_moisture: Option<String>,
pub drainage_requirement: Option<String>,
pub ph_min: Option<f64>,
pub ph_max: Option<f64>,
pub soil_texture_preference: Option<Vec<String>>,
pub hardiness_zone_usda: Option<String>,
pub hardiness_zone_at: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub drought_tolerance: Option<String>,
pub salt_tolerance: Option<String>,
pub edibility_rating: Option<i16>,
pub food_uses: Option<String>,
pub medicinal_uses: Option<String>,
pub other_uses: Option<String>,
pub native_range: Option<String>,
pub invasiveness: Option<String>,
pub pollination_type: Option<String>,
pub plant_layer: Option<String>,
pub nitrogen_fixer: Option<bool>,
pub dynamic_accumulator: Option<bool>,
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
pub attracts_pollinators: Option<bool>,
pub attracts_beneficial_insects: Option<bool>,
pub wildlife_value: Option<String>,
pub mulch_plant: Option<bool>,
pub ground_cover_quality: Option<String>,
pub allelopathic: Option<bool>,
pub guild_role: Option<Vec<String>>,
pub succession_stage: Option<String>,
pub heavy_metal_tolerance: Option<bool>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub eppo_code: Option<String>,
pub pfaf_url: Option<String>,
pub source_urls: Option<Vec<String>>,
}
pub type UpdateSpecies = CreateSpecies;
// --- Cultivars ---
#[derive(Debug, FromRow, Serialize)]
pub struct Cultivar {
pub id: Uuid,
pub slug: String,
pub species_id: Uuid,
pub name: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>,
pub is_organic: bool,
pub perennial: bool,
pub growing_time_days: Option<i32>,
pub planting_depth_cm: Option<f64>,
pub row_spacing_cm: Option<f64>,
pub plant_spacing_cm: Option<f64>,
pub days_to_germination: Option<i32>,
pub germination_temp_c: Option<f64>,
pub light_requirement: Option<String>,
pub stratification_required: Option<bool>,
pub stratification_days: Option<i32>,
pub scarification_required: Option<bool>,
pub seed_viability_years: Option<i32>,
pub storage_temp_c: Option<f64>,
pub storage_humidity: Option<String>,
pub storage_notes: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub humidity: Option<String>,
pub light: Option<String>,
pub frost_tolerance: Option<String>,
pub min_light_hours_day: Option<f64>,
pub optimal_light_hours_day: Option<f64>,
pub greenhouse_min_temp_c: Option<f64>,
pub indoor_season_extension_weeks: Option<i32>,
pub ventilation_requirement: Option<String>,
pub heating_required: Option<bool>,
pub indoor_sowing_months: Option<Vec<i32>>,
pub direct_sowing_months: Option<Vec<i32>>,
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
pub cutting_season: Option<String>,
pub rootstock_species_id: Option<Uuid>,
pub years_to_first_harvest: Option<i32>,
pub productive_lifespan_years: Option<i32>,
pub expected_yield_kg_per_m2: Option<f64>,
pub yield_unit: Option<String>,
pub expected_yield_value: Option<f64>,
pub harvest_window_days: Option<i32>,
pub storage_method: Option<Vec<String>>,
pub shelf_life_days: Option<i32>,
pub cold_storage_days: Option<i32>,
pub pollination_group: Option<String>,
pub self_fertile: Option<bool>,
pub rootstock_compatibility: Option<String>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub pfaf_url: Option<String>,
pub primary_image_key: Option<String>,
pub source_urls: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCultivar {
pub species_id: Uuid,
pub name: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>,
pub is_organic: Option<bool>,
pub perennial: Option<bool>,
pub growing_time_days: Option<i32>,
pub planting_depth_cm: Option<f64>,
pub row_spacing_cm: Option<f64>,
pub plant_spacing_cm: Option<f64>,
pub days_to_germination: Option<i32>,
pub germination_temp_c: Option<f64>,
pub light_requirement: Option<String>,
pub stratification_required: Option<bool>,
pub stratification_days: Option<i32>,
pub scarification_required: Option<bool>,
pub seed_viability_years: Option<i32>,
pub storage_temp_c: Option<f64>,
pub storage_humidity: Option<String>,
pub storage_notes: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub humidity: Option<String>,
pub light: Option<String>,
pub frost_tolerance: Option<String>,
pub min_light_hours_day: Option<f64>,
pub optimal_light_hours_day: Option<f64>,
pub greenhouse_min_temp_c: Option<f64>,
pub indoor_season_extension_weeks: Option<i32>,
pub ventilation_requirement: Option<String>,
pub heating_required: Option<bool>,
pub indoor_sowing_months: Option<Vec<i32>>,
pub direct_sowing_months: Option<Vec<i32>>,
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
pub cutting_season: Option<String>,
pub rootstock_species_id: Option<Uuid>,
pub years_to_first_harvest: Option<i32>,
pub productive_lifespan_years: Option<i32>,
pub expected_yield_kg_per_m2: Option<f64>,
pub yield_unit: Option<String>,
pub expected_yield_value: Option<f64>,
pub harvest_window_days: Option<i32>,
pub storage_method: Option<Vec<String>>,
pub shelf_life_days: Option<i32>,
pub cold_storage_days: Option<i32>,
pub pollination_group: Option<String>,
pub self_fertile: Option<bool>,
pub rootstock_compatibility: Option<String>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub pfaf_url: Option<String>,
pub source_urls: Option<Vec<String>>,
}
pub type UpdateCultivar = CreateCultivar;
// --- Suppliers ---
#[derive(Debug, FromRow, Serialize)]
pub struct Supplier {
pub id: Uuid,
pub slug: String,
pub name: String,
pub url: Option<String>,
pub is_organic: bool,
pub is_demeter: bool,
pub country: Option<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateSupplier {
pub name: String,
pub url: Option<String>,
pub is_organic: Option<bool>,
pub is_demeter: Option<bool>,
pub country: Option<String>,
pub notes: Option<String>,
}
pub type UpdateSupplier = CreateSupplier;
#[derive(Debug, FromRow, Serialize)]
pub struct CultivarSupplier {
pub id: Uuid,
pub cultivar_id: Uuid,
pub supplier_id: Uuid,
pub article_number: Option<String>,
pub product_url: Option<String>,
pub price_eur: Option<f64>,
pub pack_size: Option<f64>,
pub pack_unit: Option<String>,
pub last_checked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCultivarSupplier {
pub supplier_id: Uuid,
pub article_number: Option<String>,
pub product_url: Option<String>,
pub price_eur: Option<f64>,
pub pack_size: Option<f64>,
pub pack_unit: Option<String>,
}
// --- Companion Relationships ---
#[derive(Debug, FromRow, Serialize)]
pub struct CompanionRelationship {
pub id: Uuid,
pub species_a_id: Uuid,
pub species_b_id: Uuid,
pub relationship: String,
pub mechanism: Option<String>,
pub source_url: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCompanion {
pub species_a_id: Uuid,
pub species_b_id: Uuid,
pub relationship: String,
pub mechanism: Option<String>,
pub source_url: Option<String>,
}
// --- Images ---
#[derive(Debug, FromRow, Serialize)]
pub struct Image {
pub id: Uuid,
pub entity_type: String,
pub entity_id: Uuid,
pub s3_key: String,
pub caption: Option<String>,
pub source_url: Option<String>,
pub license: Option<String>,
pub is_primary: bool,
pub created_at: DateTime<Utc>,
}
// --- Users ---
#[derive(Debug, FromRow, Serialize)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: Option<String>,
pub nickname: Option<String>,
pub avatar_url: Option<String>,
pub provider: String,
pub provider_id: Option<String>,
pub admin: bool,
pub inserted_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// --- Search ---
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub entity_type: String,
pub id: Uuid,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub rank: f32,
}
+75
View File
@@ -0,0 +1,75 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client;
use crate::config::Config;
use crate::error::{AppError, Result};
pub fn build_client(config: &Config) -> S3Client {
let creds = aws_credential_types::Credentials::new(
&config.s3_access_key,
&config.s3_secret_key,
None,
None,
"herbapi-static",
);
let s3_config = aws_sdk_s3::config::Builder::new()
.endpoint_url(&config.s3_endpoint)
.region(aws_sdk_s3::config::Region::new(config.s3_region.clone()))
.credentials_provider(creds)
.force_path_style(true)
.behavior_version_latest()
.build();
S3Client::from_conf(s3_config)
}
pub async fn upload(
client: &S3Client,
bucket: &str,
key: &str,
data: Vec<u8>,
content_type: &str,
) -> Result<()> {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data))
.content_type(content_type)
.send()
.await
.map_err(|e| AppError::S3(format!("Upload failed for {key}: {e}")))?;
Ok(())
}
pub async fn download(client: &S3Client, bucket: &str, key: &str) -> Result<Vec<u8>> {
let resp = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| AppError::S3(format!("Download failed for {key}: {e}")))?;
let bytes = resp
.body
.collect()
.await
.map_err(|e| AppError::S3(format!("Stream read failed for {key}: {e}")))?
.into_bytes()
.to_vec();
Ok(bytes)
}
pub async fn delete(client: &S3Client, bucket: &str, key: &str) -> Result<()> {
client
.delete_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| AppError::S3(format!("Delete failed for {key}: {e}")))?;
Ok(())
}
+203
View File
@@ -0,0 +1,203 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateSpecies, PaginatedResponse, Species};
#[derive(Debug, serde::Deserialize)]
pub struct SpeciesListParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
pub family: Option<String>,
}
impl SpeciesListParams {
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
}
pub async fn list(pool: &PgPool, params: &SpeciesListParams) -> Result<PaginatedResponse<Species>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = match (&params.family, &params.search) {
(Some(family_slug), Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Species>(
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
@@ to_tsquery('english', $2)
ORDER BY s.name_scientific LIMIT $3 OFFSET $4"
)
.bind(family_slug).bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
@@ to_tsquery('english', $2)"
)
.bind(family_slug).bind(&tsquery)
.fetch_one(pool).await?;
(rows, count)
}
(Some(family_slug), None) => {
let rows = sqlx::query_as::<_, Species>(
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1 ORDER BY s.name_scientific LIMIT $2 OFFSET $3"
)
.bind(family_slug).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id WHERE f.slug = $1"
)
.bind(family_slug).fetch_one(pool).await?;
(rows, count)
}
(None, Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Species>(
"SELECT * FROM species
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY name_scientific LIMIT $2 OFFSET $3"
)
.bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery).fetch_one(pool).await?;
(rows, count)
}
(None, None) => {
let rows = sqlx::query_as::<_, Species>(
"SELECT * FROM species ORDER BY name_scientific LIMIT $1 OFFSET $2"
)
.bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM species")
.fetch_one(pool).await?;
(rows, count)
}
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Species> {
sqlx::query_as::<_, Species>("SELECT * FROM species WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Species not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateSpecies) -> Result<Species> {
let id = Uuid::now_v7();
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Species>(
"INSERT INTO species (id, slug, family_id, name_scientific, name_en, name_de, description,
soil_moisture, drainage_requirement, ph_min, ph_max, soil_texture_preference,
hardiness_zone_usda, hardiness_zone_at, min_temp, max_temp,
drought_tolerance, salt_tolerance, edibility_rating,
food_uses, medicinal_uses, other_uses, native_range, invasiveness, pollination_type,
plant_layer, nitrogen_fixer, dynamic_accumulator, dynamic_accumulator_nutrients,
attracts_pollinators, attracts_beneficial_insects, wildlife_value, mulch_plant,
ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance,
wikidata_qid, gbif_id, eppo_code, pfaf_url, source_urls)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
$17::drought_tolerance,$18::salt_tolerance,$19,$20,$21,$22,$23,
$24::invasiveness_level,$25,$26::plant_layer,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,
$37::succession_stage,$38,$39,$40,$41,$42,$43)
RETURNING *"
)
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
.bind(req.min_temp).bind(req.max_temp)
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
.bind(&req.dynamic_accumulator_nutrients)
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
.bind(&req.wildlife_value).bind(req.mulch_plant)
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
.bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSpecies) -> Result<Species> {
let _existing = sqlx::query("SELECT id FROM species WHERE id = $1")
.bind(id).fetch_optional(pool).await?
.ok_or_else(|| AppError::NotFound(format!("Species not found: {id}")))?;
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Species>(
"UPDATE species SET slug=$2, family_id=$3, name_scientific=$4, name_en=$5, name_de=$6,
description=$7, soil_moisture=$8, drainage_requirement=$9, ph_min=$10, ph_max=$11,
soil_texture_preference=$12, hardiness_zone_usda=$13, hardiness_zone_at=$14,
min_temp=$15, max_temp=$16,
drought_tolerance=$17::drought_tolerance, salt_tolerance=$18::salt_tolerance,
edibility_rating=$19, food_uses=$20, medicinal_uses=$21, other_uses=$22,
native_range=$23, invasiveness=$24::invasiveness_level, pollination_type=$25,
plant_layer=$26::plant_layer, nitrogen_fixer=$27, dynamic_accumulator=$28,
dynamic_accumulator_nutrients=$29, attracts_pollinators=$30, attracts_beneficial_insects=$31,
wildlife_value=$32, mulch_plant=$33, ground_cover_quality=$34, allelopathic=$35,
guild_role=$36, succession_stage=$37::succession_stage, heavy_metal_tolerance=$38,
wikidata_qid=$39, gbif_id=$40, eppo_code=$41, pfaf_url=$42, source_urls=$43,
updated_at=NOW()
WHERE id=$1 RETURNING *"
)
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
.bind(req.min_temp).bind(req.max_temp)
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
.bind(&req.dynamic_accumulator_nutrients)
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
.bind(&req.wildlife_value).bind(req.mulch_plant)
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
.bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM species WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Species not found: {id}")));
}
Ok(())
}
+102
View File
@@ -0,0 +1,102 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateCultivarSupplier, CreateSupplier, CultivarSupplier, Supplier};
pub async fn list(pool: &PgPool) -> Result<Vec<Supplier>> {
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers ORDER BY name")
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Supplier> {
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Supplier not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateSupplier) -> Result<Supplier> {
let id = Uuid::now_v7();
let s = slug::slugify(&req.name);
sqlx::query_as::<_, Supplier>(
"INSERT INTO suppliers (id, slug, name, url, is_organic, is_demeter, country, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
.bind(&req.country).bind(&req.notes)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSupplier) -> Result<Supplier> {
let s = slug::slugify(&req.name);
sqlx::query_as::<_, Supplier>(
"UPDATE suppliers SET slug=$2, name=$3, url=$4, is_organic=$5, is_demeter=$6,
country=$7, notes=$8, updated_at=NOW() WHERE id=$1 RETURNING *"
)
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
.bind(&req.country).bind(&req.notes)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound(format!("Supplier not found: {id}")),
other => AppError::Database(other),
})
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM suppliers WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Supplier not found: {id}")));
}
Ok(())
}
// Cultivar-Supplier links
pub async fn list_for_cultivar(pool: &PgPool, cultivar_id: Uuid) -> Result<Vec<CultivarSupplier>> {
sqlx::query_as::<_, CultivarSupplier>(
"SELECT * FROM cultivar_suppliers WHERE cultivar_id = $1 ORDER BY created_at"
)
.bind(cultivar_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn link_cultivar(pool: &PgPool, cultivar_id: Uuid, req: &CreateCultivarSupplier) -> Result<CultivarSupplier> {
let id = Uuid::now_v7();
sqlx::query_as::<_, CultivarSupplier>(
"INSERT INTO cultivar_suppliers (id, cultivar_id, supplier_id, article_number, product_url,
price_eur, pack_size, pack_unit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(cultivar_id).bind(req.supplier_id)
.bind(&req.article_number).bind(&req.product_url)
.bind(req.price_eur).bind(req.pack_size).bind(&req.pack_unit)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn unlink_cultivar(pool: &PgPool, cultivar_id: Uuid, supplier_id: Uuid) -> Result<()> {
let result = sqlx::query(
"DELETE FROM cultivar_suppliers WHERE cultivar_id = $1 AND supplier_id = $2"
)
.bind(cultivar_id).bind(supplier_id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Link not found".into()));
}
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::Result;
use super::models::User;
pub async fn upsert_oidc_user(
pool: &PgPool,
email: &str,
name: Option<&str>,
nickname: Option<&str>,
provider_id: &str,
) -> Result<User> {
sqlx::query_as::<_, User>(
"INSERT INTO users (email, name, nickname, provider, provider_id)
VALUES ($1, $2, $3, 'authentik', $4)
ON CONFLICT (provider, provider_id)
DO UPDATE SET email = $1, name = $2, nickname = $3, updated_at = NOW()
RETURNING *"
)
.bind(email).bind(name).bind(nickname).bind(provider_id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn find_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(Into::into)
}