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:
@@ -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(())
|
||||
}
|
||||
@@ -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 (¶ms.species, ¶ms.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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}")))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 (¶ms.family, ¶ms.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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user