Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
219 lines
7.1 KiB
Go
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})
|
|
}
|