package api import ( "encoding/json" "errors" "net/http" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // plantCreateRequest is the body for POST /plants. spacingCm is centimeters; // color is a hex string; icon is an emoji. daysToMaturity is optional. type plantCreateRequest struct { Name string `json:"name" binding:"required"` Category string `json:"category" binding:"required"` SpacingCM float64 `json:"spacingCm"` Color string `json:"color" binding:"required"` Icon string `json:"icon" binding:"required"` DaysToMaturity *int `json:"daysToMaturity"` Notes string `json:"notes"` } func (r plantCreateRequest) toInput() service.PlantInput { return service.PlantInput{ Name: r.Name, Category: r.Category, SpacingCM: r.SpacingCM, Color: r.Color, Icon: r.Icon, DaysToMaturity: r.DaysToMaturity, Notes: r.Notes, } } // plantUpdateRequest is the body for PATCH /plants/:id: every field optional // (absent = unchanged), plus the required current version. daysToMaturity is // json.RawMessage so an explicit null (clear it) is distinct from an absent // field (unchanged). type plantUpdateRequest struct { Name *string `json:"name"` Category *string `json:"category"` SpacingCM *float64 `json:"spacingCm"` Color *string `json:"color"` Icon *string `json:"icon"` DaysToMaturity json.RawMessage `json:"daysToMaturity"` Notes *string `json:"notes"` Version int64 `json:"version" binding:"required,min=1"` } func (r plantUpdateRequest) toPatch() (service.PlantPatch, error) { days, setDays, err := parseNullableInt(r.DaysToMaturity) if err != nil { return service.PlantPatch{}, err } return service.PlantPatch{ Name: r.Name, Category: r.Category, SpacingCM: r.SpacingCM, Color: r.Color, Icon: r.Icon, SetDays: setDays, DaysToMaturity: days, Notes: r.Notes, }, nil } // parseNullableInt decodes a JSON number|null field into (value, present). // Absent → (nil, false); null → (nil, true); 42 → (&42, true). A non-integer // value is an error. func parseNullableInt(raw json.RawMessage) (value *int, present bool, err error) { if len(raw) == 0 { return nil, false, nil } if err := json.Unmarshal(raw, &value); err != nil { return nil, false, err } return value, true, nil } func (h *handlers) listPlants(c *gin.Context) { plants, err := h.svc.ListPlants(c.Request.Context(), mustActor(c).ID) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusOK, plants) } func (h *handlers) createPlant(c *gin.Context) { var req plantCreateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "name, category, color and icon are required") return } p, err := h.svc.CreatePlant(c.Request.Context(), mustActor(c).ID, req.toInput()) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusCreated, p) } func (h *handlers) updatePlant(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } var req plantUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required") return } patch, err := req.toPatch() if err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update payload") return } p, err := h.svc.UpdatePlant(c.Request.Context(), mustActor(c).ID, id, patch, req.Version) if err != nil { if errors.Is(err, domain.ErrVersionConflict) { writeVersionConflict(c, p) return } writeServiceError(c, err) return } c.JSON(http.StatusOK, p) } func (h *handlers) deletePlant(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } if err := h.svc.DeletePlant(c.Request.Context(), mustActor(c).ID, id); err != nil { writeServiceError(c, err) return } c.Status(http.StatusNoContent) }