package storage import ( "encoding/json" "strings" "time" "gardomatic.kleiax.de/internal/platform/validate" ) // SpeciesModelInterface persists global and garden-specific species data. type SpeciesModelInterface interface { Insert(species Species) (Species, error) Get(gardenID, id int) (Species, error) GetAllForGarden(gardenID int) ([]Species, error) Update(gardenID int, species Species) (Species, error) Delete(gardenID, id int) error } // Species contains reusable botanical and cultivation master data. type Species struct { ID int `json:"id"` GardenID *int `json:"garden_id,omitempty"` CommonName string `json:"common_name"` Cultivar string `json:"cultivar"` BotanicalName string `json:"botanical_name"` CategoryID *int `json:"category_id,omitempty"` Category string `json:"category"` SunExposure *string `json:"sun_exposure,omitempty"` SoilCondition *string `json:"soil_condition,omitempty"` SoilReaction *string `json:"soil_reaction,omitempty"` WinterProtection *string `json:"winter_protection,omitempty"` SpacingCM *int `json:"spacing_cm,omitempty"` // HeightCM is retained as a deprecated API alias for HeightCMTo. HeightCM *int `json:"height_cm,omitempty"` HeightCMFrom *int `json:"height_cm_from,omitempty"` HeightCMTo *int `json:"height_cm_to,omitempty"` WidthCMFrom *int `json:"width_cm_from,omitempty"` WidthCMTo *int `json:"width_cm_to,omitempty"` SowMonthFrom *int `json:"sow_month_from,omitempty"` SowDayFrom *int `json:"sow_day_from,omitempty"` SowMonthTo *int `json:"sow_month_to,omitempty"` SowDayTo *int `json:"sow_day_to,omitempty"` PlantingMonthFrom *int `json:"planting_month_from,omitempty"` PlantingDayFrom *int `json:"planting_day_from,omitempty"` PlantingMonthTo *int `json:"planting_month_to,omitempty"` PlantingDayTo *int `json:"planting_day_to,omitempty"` HarvestMonthFrom *int `json:"harvest_month_from,omitempty"` HarvestDayFrom *int `json:"harvest_day_from,omitempty"` HarvestMonthTo *int `json:"harvest_month_to,omitempty"` HarvestDayTo *int `json:"harvest_day_to,omitempty"` Notes string `json:"notes"` ImageData string `json:"image_data,omitempty"` ImageID *int `json:"image_id,omitempty"` Attributes json.RawMessage `json:"attributes"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Version int `json:"version"` Tags []string `json:"tags,omitempty"` CreatedBy int `json:"created_by"` UpdatedBy int `json:"updated_by"` } // ValidateSpecies applies persistence-independent species validation rules. func ValidateSpecies(v *validate.Validator, species Species) { v.Check(strings.TrimSpace(species.CommonName) != "", "common_name", "must be provided") v.Check(len(species.CommonName) <= 500, "common_name", "must not be more than 500 bytes long") v.Check(len(species.Cultivar) <= 500, "cultivar", "must not be more than 500 bytes long") v.Check(len(species.BotanicalName) <= 500, "botanical_name", "must not be more than 500 bytes long") if species.CategoryID != nil { v.Check(*species.CategoryID > 0, "category_id", "must be a positive integer") } v.Check(len(species.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long") v.Check(len(species.Attributes) == 0 || json.Valid(species.Attributes), "attributes", "must be valid JSON") ValidateTags(v, species.Tags) validateOptionalEnum(v, "sun_exposure", species.SunExposure, "sunny", "partial_shade", "shade") validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy") validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral") validateOptionalPositive(v, "spacing_cm", species.SpacingCM) heightTo := species.HeightCMTo if heightTo == nil { heightTo = species.HeightCM } validateOptionalRange(v, "height_cm", species.HeightCMFrom, heightTo) validateOptionalRange(v, "width_cm", species.WidthCMFrom, species.WidthCMTo) validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12) validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31) validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12) validateOptionalCalendarPart(v, "sow_day_to", species.SowDayTo, 31) validateOptionalCalendarPart(v, "planting_month_from", species.PlantingMonthFrom, 12) validateOptionalCalendarPart(v, "planting_day_from", species.PlantingDayFrom, 31) validateOptionalCalendarPart(v, "planting_month_to", species.PlantingMonthTo, 12) validateOptionalCalendarPart(v, "planting_day_to", species.PlantingDayTo, 31) validateOptionalCalendarPart(v, "harvest_month_from", species.HarvestMonthFrom, 12) validateOptionalCalendarPart(v, "harvest_day_from", species.HarvestDayFrom, 31) validateOptionalCalendarPart(v, "harvest_month_to", species.HarvestMonthTo, 12) validateOptionalCalendarPart(v, "harvest_day_to", species.HarvestDayTo, 31) } func validateOptionalEnum(v *validate.Validator, field string, value *string, allowed ...string) { if value != nil { v.Check(validate.PermittedValue(*value, allowed...), field, "is invalid") } } func validateOptionalPositive(v *validate.Validator, field string, value *int) { if value != nil { v.Check(*value > 0, field, "must be greater than zero") } } func validateOptionalRange(v *validate.Validator, field string, from, to *int) { validateOptionalPositive(v, field+"_from", from) validateOptionalPositive(v, field+"_to", to) if from != nil && to != nil { v.Check(*from <= *to, field+"_from", "must not be greater than "+field+"_to") } } func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) { if value != nil { v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range") } }