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" ) // plantingCreateRequest is the body for POST /objects/:id/plantings. x/y are in // the object's local frame (origin at object center); count omitted = derived; // plantedAt omitted = today. type plantingCreateRequest struct { PlantID int64 `json:"plantId" binding:"required"` XCM float64 `json:"xCm"` YCM float64 `json:"yCm"` RadiusCM float64 `json:"radiusCm"` Count *int `json:"count"` Label *string `json:"label"` PlantedAt *string `json:"plantedAt"` SeedLotID *int64 `json:"seedLotId"` } func (r plantingCreateRequest) toInput() service.PlantingInput { return service.PlantingInput{ PlantID: r.PlantID, XCM: r.XCM, YCM: r.YCM, RadiusCM: r.RadiusCM, Count: r.Count, Label: r.Label, PlantedAt: r.PlantedAt, SeedLotID: r.SeedLotID, } } // plantingUpdateRequest is the body for PATCH /plantings/:id: every field // optional (absent = unchanged) plus the required current version. The nullable // fields are json.RawMessage so an explicit null (clear) is distinct from an // absent field (unchanged) — e.g. `removedAt: "2026-07-01"` soft-removes and // `removedAt: null` un-removes. type plantingUpdateRequest struct { PlantID *int64 `json:"plantId"` XCM *float64 `json:"xCm"` YCM *float64 `json:"yCm"` RadiusCM *float64 `json:"radiusCm"` Count json.RawMessage `json:"count"` Label json.RawMessage `json:"label"` PlantedAt json.RawMessage `json:"plantedAt"` RemovedAt json.RawMessage `json:"removedAt"` SeedLotID json.RawMessage `json:"seedLotId"` Version int64 `json:"version" binding:"required,min=1"` } func (r plantingUpdateRequest) toPatch() (service.PlantingPatch, error) { count, setCount, err := parseNullableInt(r.Count) if err != nil { return service.PlantingPatch{}, err } label, setLabel, err := parseNullableString(r.Label) if err != nil { return service.PlantingPatch{}, err } plantedAt, setPlantedAt, err := parseNullableString(r.PlantedAt) if err != nil { return service.PlantingPatch{}, err } seedLot, setSeedLot, err := parseNullable[int64](r.SeedLotID) if err != nil { return service.PlantingPatch{}, err } removedAt, setRemovedAt, err := parseNullableString(r.RemovedAt) if err != nil { return service.PlantingPatch{}, err } return service.PlantingPatch{ PlantID: r.PlantID, XCM: r.XCM, YCM: r.YCM, RadiusCM: r.RadiusCM, SetCount: setCount, Count: count, SetLabel: setLabel, Label: label, SetPlantedAt: setPlantedAt, PlantedAt: plantedAt, SetRemovedAt: setRemovedAt, RemovedAt: removedAt, SetSeedLotID: setSeedLot, SeedLotID: seedLot, }, nil } func (h *handlers) createPlanting(c *gin.Context) { objectID, ok := parseIDParam(c, "id") if !ok { return } var req plantingCreateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid planting: a plantId is required") return } p, err := h.svc.CreatePlanting(c.Request.Context(), mustActor(c).ID, objectID, req.toInput()) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusCreated, p) } func (h *handlers) updatePlanting(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } var req plantingUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: 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.UpdatePlanting(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) deletePlanting(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } if err := h.svc.DeletePlanting(c.Request.Context(), mustActor(c).ID, id); err != nil { writeServiceError(c, err) return } c.Status(http.StatusNoContent) }