Files
steve 8c5ddb21ec
Build image / build-and-push (push) Successful in 17s
Season view: filter the editor to a year (#54) (#66)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:47:34 +00:00

219 lines
7.1 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// objectCreateRequest is the body for POST /gardens/:id/objects. Dimensions are
// centimeters; the object is positioned by its center. Plantable/color/props are
// optional (plantable defaults by kind). props is any JSON value, stored as-is.
type objectCreateRequest struct {
Kind string `json:"kind" binding:"required"`
Name string `json:"name"`
Shape string `json:"shape"`
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
RotationDeg float64 `json:"rotationDeg"`
ZIndex int `json:"zIndex"`
Plantable *bool `json:"plantable"`
Color *string `json:"color"`
Props json.RawMessage `json:"props"`
GridSizeCM float64 `json:"gridSizeCm"`
SnapToGrid bool `json:"snapToGrid"`
Notes string `json:"notes"`
}
func (r objectCreateRequest) toInput() service.ObjectInput {
return service.ObjectInput{
Kind: r.Kind, Name: r.Name, Shape: r.Shape,
XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable,
Color: r.Color, Props: propsFromRaw(r.Props),
GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes,
}
}
// objectUpdateRequest is the body for PATCH /objects/:id: every field optional
// (absent = unchanged), plus the required current version. Kind and shape are
// immutable and not accepted. color/props are json.RawMessage so an explicit
// null (clear the override) is distinguishable from an absent field (unchanged).
type objectUpdateRequest struct {
Name *string `json:"name"`
XCM *float64 `json:"xCm"`
YCM *float64 `json:"yCm"`
WidthCM *float64 `json:"widthCm"`
HeightCM *float64 `json:"heightCm"`
RotationDeg *float64 `json:"rotationDeg"`
ZIndex *int `json:"zIndex"`
Plantable *bool `json:"plantable"`
Color json.RawMessage `json:"color"`
Props json.RawMessage `json:"props"`
GridSizeCM *float64 `json:"gridSizeCm"`
SnapToGrid *bool `json:"snapToGrid"`
Notes *string `json:"notes"`
Version int64 `json:"version" binding:"required,min=1"`
}
func (r objectUpdateRequest) toPatch() (service.ObjectPatch, error) {
color, setColor, err := parseNullableString(r.Color)
if err != nil {
return service.ObjectPatch{}, err
}
props, setProps := parseNullableJSON(r.Props)
return service.ObjectPatch{
Name: r.Name, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable,
SetColor: setColor, Color: color, SetProps: setProps, Props: props,
GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes,
}, nil
}
// propsFromRaw maps a JSON props value to the stored *string for create: a
// present, non-null value becomes its JSON text; absent or null becomes nil.
func propsFromRaw(raw json.RawMessage) *string {
v, set := parseNullableJSON(raw)
if !set {
return nil
}
return v
}
// parseNullableString decodes a JSON string|null field into (value, present).
// Absent → (nil, false); null → (nil, true); "x" → (&"x", true). A non-string
// value is an error.
func parseNullableString(raw json.RawMessage) (value *string, 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
}
// parseNullableJSON decodes any JSON value into (text, present) for a nullable
// TEXT column. Absent → (nil, false); null → (nil, true); anything else → the
// raw JSON text (&, true).
func parseNullableJSON(raw json.RawMessage) (value *string, present bool) {
if len(raw) == 0 {
return nil, false
}
if string(raw) == "null" {
return nil, true
}
s := string(raw)
return &s, true
}
func (h *handlers) createObject(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
var req objectCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid object: kind is required")
return
}
o, err := h.svc.CreateObject(c.Request.Context(), mustActor(c).ID, gardenID, req.toInput())
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, o)
}
func (h *handlers) updateObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req objectUpdateRequest
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
}
o, err := h.svc.UpdateObject(c.Request.Context(), mustActor(c).ID, id, patch, req.Version)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, o)
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, o)
}
func (h *handlers) deleteObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.DeleteObject(c.Request.Context(), mustActor(c).ID, id); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// getGardenFull serves the editor's one-shot load. ?year=YYYY switches it to the
// season view: every plop whose time in the ground overlapped that calendar
// year, INCLUDING ones since removed.
//
// Undated plantings are returned for every year. Everything planted before this
// feature existed has a null planted_at, so excluding them would empty every
// garden the moment a year was picked — technically defensible, useless in
// practice. Without the param the behaviour is exactly what it has always been.
func (h *handlers) getGardenFull(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
var year *int
if raw := c.Query("year"); raw != "" {
y, err := strconv.Atoi(raw)
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "year must be a four-digit year")
return
}
year = &y
}
full, err := h.svc.GardenFull(c.Request.Context(), mustActor(c).ID, gardenID, year)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, full)
}
// getGardenYears lists the years this garden has planting data for, so the year
// selector can offer only years that hold something.
func (h *handlers) getGardenYears(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
years, err := h.svc.GardenYears(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"years": years})
}