package api import ( "encoding/json" "errors" "fmt" "net/http" "strings" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" ) type speciesInput struct { Global bool `json:"global"` Tags []string `json:"tags"` CommonName *string `json:"common_name"` Cultivar *string `json:"cultivar"` BotanicalName *string `json:"botanical_name"` CategoryID *int `json:"category_id"` ClearCategoryID bool `json:"clear_category_id"` SunExposure *string `json:"sun_exposure"` SoilCondition *string `json:"soil_condition"` SoilReaction *string `json:"soil_reaction"` WinterProtection *string `json:"winter_protection"` SpacingCM *int `json:"spacing_cm"` HeightCM *int `json:"height_cm"` HeightCMFrom *int `json:"height_cm_from"` HeightCMTo *int `json:"height_cm_to"` WidthCMFrom *int `json:"width_cm_from"` WidthCMTo *int `json:"width_cm_to"` ClearHeightCMFrom bool `json:"clear_height_cm_from"` ClearHeightCMTo bool `json:"clear_height_cm_to"` ClearWidthCMFrom bool `json:"clear_width_cm_from"` ClearWidthCMTo bool `json:"clear_width_cm_to"` SowMonthFrom *int `json:"sow_month_from"` SowDayFrom *int `json:"sow_day_from"` ClearSowDayFrom bool `json:"clear_sow_day_from"` SowMonthTo *int `json:"sow_month_to"` SowDayTo *int `json:"sow_day_to"` ClearSowDayTo bool `json:"clear_sow_day_to"` PlantingMonthFrom *int `json:"planting_month_from"` PlantingDayFrom *int `json:"planting_day_from"` ClearPlantingDayFrom bool `json:"clear_planting_day_from"` PlantingMonthTo *int `json:"planting_month_to"` PlantingDayTo *int `json:"planting_day_to"` ClearPlantingDayTo bool `json:"clear_planting_day_to"` HarvestMonthFrom *int `json:"harvest_month_from"` HarvestDayFrom *int `json:"harvest_day_from"` ClearHarvestDayFrom bool `json:"clear_harvest_day_from"` HarvestMonthTo *int `json:"harvest_month_to"` HarvestDayTo *int `json:"harvest_day_to"` ClearHarvestDayTo bool `json:"clear_harvest_day_to"` ClearSowRange bool `json:"clear_sow_range"` ClearPlantingRange bool `json:"clear_planting_range"` ClearHarvestRange bool `json:"clear_harvest_range"` Notes *string `json:"notes"` ImageData *string `json:"image_data"` ImageID *int `json:"image_id"` Attributes json.RawMessage `json:"attributes"` } func (input speciesInput) apply(species *storage.Species) { assignTrimmed(input.CommonName, &species.CommonName) assignTrimmed(input.Cultivar, &species.Cultivar) assignTrimmed(input.BotanicalName, &species.BotanicalName) assignIntPointer(input.CategoryID, &species.CategoryID) if input.ClearCategoryID { species.CategoryID = nil species.Category = "" } assignTrimmed(input.Notes, &species.Notes) if input.ImageData != nil { species.ImageData = *input.ImageData } assignStringPointer(input.SunExposure, &species.SunExposure) assignStringPointer(input.SoilCondition, &species.SoilCondition) assignStringPointer(input.SoilReaction, &species.SoilReaction) assignStringPointer(input.WinterProtection, &species.WinterProtection) assignIntPointer(input.SpacingCM, &species.SpacingCM) assignIntPointer(input.HeightCMFrom, &species.HeightCMFrom) if input.HeightCMTo != nil { assignIntPointer(input.HeightCMTo, &species.HeightCMTo) } else { assignIntPointer(input.HeightCM, &species.HeightCMTo) } assignIntPointer(input.WidthCMFrom, &species.WidthCMFrom) assignIntPointer(input.WidthCMTo, &species.WidthCMTo) clearIntPointer(input.ClearHeightCMFrom, &species.HeightCMFrom) clearIntPointer(input.ClearHeightCMTo, &species.HeightCMTo) clearIntPointer(input.ClearWidthCMFrom, &species.WidthCMFrom) clearIntPointer(input.ClearWidthCMTo, &species.WidthCMTo) species.HeightCM = species.HeightCMTo assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom) assignIntPointer(input.SowDayFrom, &species.SowDayFrom) assignIntPointer(input.SowMonthTo, &species.SowMonthTo) assignIntPointer(input.SowDayTo, &species.SowDayTo) assignIntPointer(input.PlantingMonthFrom, &species.PlantingMonthFrom) assignIntPointer(input.PlantingDayFrom, &species.PlantingDayFrom) assignIntPointer(input.PlantingMonthTo, &species.PlantingMonthTo) assignIntPointer(input.PlantingDayTo, &species.PlantingDayTo) assignIntPointer(input.HarvestMonthFrom, &species.HarvestMonthFrom) assignIntPointer(input.HarvestDayFrom, &species.HarvestDayFrom) assignIntPointer(input.HarvestMonthTo, &species.HarvestMonthTo) assignIntPointer(input.HarvestDayTo, &species.HarvestDayTo) if input.ClearSowRange { species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo = nil, nil, nil, nil } if input.ClearHarvestRange { species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo = nil, nil, nil, nil } if input.ClearPlantingRange { species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo = nil, nil, nil, nil } if input.ClearSowDayFrom { species.SowDayFrom = nil } if input.ClearSowDayTo { species.SowDayTo = nil } if input.ClearPlantingDayFrom { species.PlantingDayFrom = nil } if input.ClearPlantingDayTo { species.PlantingDayTo = nil } if input.ClearHarvestDayFrom { species.HarvestDayFrom = nil } if input.ClearHarvestDayTo { species.HarvestDayTo = nil } if len(input.Attributes) != 0 { species.Attributes = input.Attributes } } func assignTrimmed(input *string, destination *string) { if input != nil { *destination = strings.TrimSpace(*input) } } func assignStringPointer(input *string, destination **string) { if input != nil { value := strings.TrimSpace(*input) if value == "" { *destination = nil } else { *destination = &value } } } func assignIntPointer(input *int, destination **int) { if input != nil { *destination = input } } func clearIntPointer(clear bool, destination **int) { if clear { *destination = nil } } func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) var input speciesInput if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } member, _ := app.contextGetGardenMember(r) user, _ := app.contextGetAuthenticatedUser(r) if input.Global { if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { app.permissionDeniedResponse(w, r) return } } else if !member.Can(storage.GardenPermissionSpeciesWrite) { app.permissionDeniedResponse(w, r) return } var ownerGardenID *int if !input.Global { ownerGardenID = &gardenID } species := storage.Species{GardenID: ownerGardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID} input.apply(&species) changedCategoryID := input.CategoryID if input.ClearCategoryID { changedCategoryID = nil } if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, nil) { return } species.Tags = storage.NormalizeTags(input.Tags) v := validate.New() validateImageData(v, species.ImageData) if storage.ValidateSpecies(v, species); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } imageID, err := app.resolveImage(gardenID, species.ImageData, input.ImageID, user.ID, "species") if err != nil { app.badRequestResponse(w, r, err) return } species.ImageID = imageID species.ImageData = "" species, err = app.models.Species.Insert(species) if err != nil { app.respondToEntityModelError(w, r, err) return } if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { app.serverErrorResponse(w, r, err) return } tagScope := gardenID if species.GardenID == nil { tagScope = 0 } species.Tags, err = app.saveTags(tagScope, storage.TagEntitySpecies, species.ID, species.Tags) if err != nil { app.serverErrorResponse(w, r, err) return } headers := make(http.Header) headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d", gardenID, species.ID)) if err := app.writeJSON(w, http.StatusCreated, envelope{"species": species}, headers); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) listSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) allSpecies, err := app.models.Species.GetAllForGarden(gardenID) if err != nil { app.serverErrorResponse(w, r, err) return } for i := range allSpecies { tagScope := gardenID if allSpecies[i].GardenID == nil { tagScope = 0 } allSpecies[i].Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, allSpecies[i].ID) if err != nil { app.serverErrorResponse(w, r, err) return } } if err := app.writeJSON(w, http.StatusOK, envelope{"species": allSpecies}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) showSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } species, err := app.models.Species.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } tagScope := gardenID if species.GardenID == nil { tagScope = 0 } species.Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, species.ID) if err != nil { app.serverErrorResponse(w, r, err) return } if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) updateSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } species, err := app.models.Species.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } member, _ := app.contextGetGardenMember(r) user, _ := app.contextGetAuthenticatedUser(r) if species.GardenID == nil { if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { app.permissionDeniedResponse(w, r) return } } else if *species.GardenID != gardenID { app.notFoundResponse(w, r) return } else if !member.Can(storage.GardenPermissionSpeciesWrite) { app.permissionDeniedResponse(w, r) return } var input speciesInput if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } previousCategoryID := species.CategoryID previousImageID := species.ImageID previousImageData := species.ImageData input.apply(&species) species.UpdatedBy = user.ID changedCategoryID := input.CategoryID if input.ClearCategoryID { changedCategoryID = nil } if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, previousCategoryID) { return } if input.Tags != nil { species.Tags = storage.NormalizeTags(input.Tags) } v := validate.New() validateImageData(v, species.ImageData) if storage.ValidateSpecies(v, species); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } if input.ImageData != nil && *input.ImageData != previousImageData { species.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "species") if err != nil { app.badRequestResponse(w, r, err) return } } species.ImageData = "" updateScope := gardenID if species.GardenID == nil { updateScope = 0 } species, err = app.models.Species.Update(updateScope, species) if err != nil { app.respondToEntityModelError(w, r, err) return } if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { app.serverErrorResponse(w, r, err) return } if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "species", EntityID: species.ID, PreviousImageID: previousImageID, ImageID: species.ImageID, ChangedBy: user.ID}); err != nil { app.serverErrorResponse(w, r, err) return } if input.Tags != nil { species.Tags, err = app.saveTags(updateScope, storage.TagEntitySpecies, species.ID, species.Tags) if err != nil { app.serverErrorResponse(w, r, err) return } } if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) resolveSpeciesCategory(w http.ResponseWriter, r *http.Request, species *storage.Species, changedID, existingID *int) bool { if changedID == nil { return true } category, err := app.models.SpeciesCategories.Get(*changedID) keepsInactiveCategory := existingID != nil && *existingID == *changedID if err != nil || !category.Active && !keepsInactiveCategory { app.failedValidationResponse(w, r, map[string]string{"category_id": "must reference an active category"}) return false } species.Category = category.Name return true } func (app *application) deleteSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } species, writable := app.requireWritableSpecies(w, r, gardenID, id) if !writable { return } deleteScope := gardenID if species.GardenID == nil { deleteScope = 0 } if err := app.models.Species.Delete(deleteScope, id); err != nil { app.respondToEntityModelError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (app *application) respondToEntityModelError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, storage.ErrRecordNotFound): app.notFoundResponse(w, r) case errors.Is(err, storage.ErrEditConflict): app.editConflictResponse(w, r) case errors.Is(err, storage.ErrConflict): app.conflictResponse(w, r) default: app.serverErrorResponse(w, r, err) } }