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
@@ -0,0 +1,16 @@
CREATE TABLE families (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name_scientific TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_families_search ON families
USING GIN (to_tsvector('english',
coalesce(name_scientific,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'')));
@@ -0,0 +1,77 @@
CREATE TYPE invasiveness_level AS ENUM ('none', 'watch_list', 'invasive', 'banned');
CREATE TYPE plant_layer AS ENUM ('canopy', 'understory', 'shrub', 'herbaceous', 'ground_cover', 'vine', 'root');
CREATE TYPE succession_stage AS ENUM ('pioneer', 'early', 'mid', 'climax');
CREATE TYPE drought_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
CREATE TYPE salt_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
CREATE TABLE species (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
family_id UUID NOT NULL REFERENCES families(id) ON DELETE RESTRICT,
name_scientific TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
description TEXT,
soil_moisture TEXT,
drainage_requirement TEXT,
organic_matter_pct NUMERIC(5,2),
nitrogen_ppm INTEGER,
phosphorus_ppm INTEGER,
potassium_ppm INTEGER,
boron_ppm NUMERIC(8,2),
calcium_ppm INTEGER,
copper_ppm NUMERIC(8,2),
iron_ppm NUMERIC(8,2),
magnesium_ppm INTEGER,
manganese_ppm NUMERIC(8,2),
molybdenum_ppm NUMERIC(8,2),
sulfur_ppm INTEGER,
zinc_ppm NUMERIC(8,2),
ph_min NUMERIC(4,2),
ph_max NUMERIC(4,2),
soil_texture_preference TEXT[],
hardiness_zone_usda TEXT,
hardiness_zone_at TEXT,
min_temp NUMERIC(5,2),
max_temp NUMERIC(5,2),
drought_tolerance drought_tolerance,
water_requirement_mm_week NUMERIC(5,2),
waterlogging_tolerance BOOLEAN,
salt_tolerance salt_tolerance,
edibility_rating SMALLINT,
food_uses TEXT,
medicinal_uses TEXT,
other_uses TEXT,
native_range TEXT,
invasiveness invasiveness_level DEFAULT 'none',
pollination_type TEXT,
plant_layer plant_layer,
nitrogen_fixer BOOLEAN,
dynamic_accumulator BOOLEAN,
dynamic_accumulator_nutrients TEXT[],
attracts_pollinators BOOLEAN,
attracts_beneficial_insects BOOLEAN,
wildlife_value TEXT,
mulch_plant BOOLEAN,
ground_cover_quality TEXT,
allelopathic BOOLEAN,
guild_role TEXT[],
succession_stage succession_stage,
heavy_metal_tolerance BOOLEAN,
wikidata_qid TEXT,
gbif_id TEXT,
eppo_code TEXT,
pfaf_url TEXT,
primary_image_key TEXT,
source_urls TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_species_family ON species(family_id);
CREATE INDEX idx_species_search ON species
USING GIN (to_tsvector('english',
coalesce(name_scientific,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'') || ' ' ||
coalesce(description,'')));
@@ -0,0 +1,78 @@
CREATE TYPE frost_tolerance AS ENUM ('none', 'light_frost', 'moderate_frost', 'hardy');
CREATE TABLE cultivars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
name_scientific TEXT,
description TEXT,
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
perennial BOOLEAN NOT NULL DEFAULT FALSE,
growing_time_days INTEGER,
planting_depth_cm NUMERIC(5,2),
row_spacing_cm NUMERIC(5,2),
plant_spacing_cm NUMERIC(5,2),
days_to_germination INTEGER,
germination_temp_c NUMERIC(5,2),
light_requirement TEXT,
stratification_required BOOLEAN,
stratification_days INTEGER,
scarification_required BOOLEAN,
seed_viability_years INTEGER,
storage_temp_c NUMERIC(5,2),
storage_humidity TEXT,
storage_notes TEXT,
min_temp NUMERIC(5,2),
max_temp NUMERIC(5,2),
humidity TEXT,
light TEXT,
frost_tolerance frost_tolerance,
min_light_hours_day NUMERIC(4,1),
optimal_light_hours_day NUMERIC(4,1),
greenhouse_min_temp_c NUMERIC(5,2),
indoor_season_extension_weeks INTEGER,
ventilation_requirement TEXT,
heating_required BOOLEAN,
indoor_sowing_months INTEGER[],
direct_sowing_months INTEGER[],
transplanting_months INTEGER[],
glasshouse_months INTEGER[],
harvesting_months INTEGER[],
succession_planting_days INTEGER,
planting_notes TEXT,
propagation_methods TEXT[],
cutting_season TEXT,
rootstock_species_id UUID REFERENCES species(id),
years_to_first_harvest INTEGER,
productive_lifespan_years INTEGER,
expected_yield_kg_per_m2 NUMERIC(8,2),
yield_unit TEXT,
expected_yield_value NUMERIC(8,2),
harvest_window_days INTEGER,
storage_method TEXT[],
shelf_life_days INTEGER,
cold_storage_days INTEGER,
pollination_group TEXT,
self_fertile BOOLEAN,
rootstock_compatibility TEXT,
wikidata_qid TEXT,
gbif_id TEXT,
pfaf_url TEXT,
primary_image_key TEXT,
source_urls TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cultivars_species ON cultivars(species_id);
CREATE INDEX idx_cultivars_rootstock ON cultivars(rootstock_species_id) WHERE rootstock_species_id IS NOT NULL;
CREATE INDEX idx_cultivars_search ON cultivars
USING GIN (to_tsvector('english',
coalesce(name,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'') || ' ' ||
coalesce(name_scientific,'') || ' ' ||
coalesce(description,'')));
@@ -0,0 +1,13 @@
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
s3_key TEXT NOT NULL,
caption TEXT,
source_url TEXT,
license TEXT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_images_entity ON images(entity_type, entity_id);
@@ -0,0 +1,12 @@
CREATE TABLE scrape_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_name TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
source_url TEXT NOT NULL,
last_scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_data JSONB
);
CREATE INDEX idx_scrape_entity ON scrape_log(entity_type, entity_id);
CREATE INDEX idx_scrape_source ON scrape_log(source_name);
@@ -0,0 +1,29 @@
CREATE TABLE suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
url TEXT,
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
is_demeter BOOLEAN NOT NULL DEFAULT FALSE,
country TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cultivar_suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cultivar_id UUID NOT NULL REFERENCES cultivars(id) ON DELETE CASCADE,
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
article_number TEXT,
product_url TEXT,
price_eur NUMERIC(8,2),
pack_size NUMERIC(8,2),
pack_unit TEXT,
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(cultivar_id, supplier_id, article_number)
);
CREATE INDEX idx_cs_cultivar ON cultivar_suppliers(cultivar_id);
CREATE INDEX idx_cs_supplier ON cultivar_suppliers(supplier_id);
@@ -0,0 +1,16 @@
CREATE TYPE companion_type AS ENUM ('beneficial', 'neutral', 'antagonistic');
CREATE TABLE companion_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
species_a_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
species_b_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
relationship companion_type NOT NULL,
mechanism TEXT,
source_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(species_a_id, species_b_id),
CHECK (species_a_id < species_b_id)
);
CREATE INDEX idx_companion_a ON companion_relationships(species_a_id);
CREATE INDEX idx_companion_b ON companion_relationships(species_b_id);
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR NOT NULL,
name VARCHAR,
nickname VARCHAR,
avatar_url VARCHAR,
provider VARCHAR NOT NULL DEFAULT 'authentik',
provider_id VARCHAR,
admin BOOLEAN NOT NULL DEFAULT FALSE,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_index ON users (email);
CREATE UNIQUE INDEX IF NOT EXISTS users_provider_provider_id_index ON users (provider, provider_id);
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);