diff --git a/internal/api/api.go b/internal/api/api.go index 298b5dd..dbab6dc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -89,6 +89,13 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { objects := v1.Group("/objects", h.requireAuth()) objects.PATCH("/:id", h.updateObject) objects.DELETE("/:id", h.deleteObject) + objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object + + // Plantings ("plops") are addressed by their own id; the service resolves the + // owning object/garden for the permission check. + plantings := v1.Group("/plantings", h.requireAuth()) + plantings.PATCH("/:id", h.updatePlanting) + plantings.DELETE("/:id", h.deletePlanting) // Plant catalog: built-ins (seeded, read-only) plus the actor's own rows. plants := v1.Group("/plants", h.requireAuth()) diff --git a/internal/api/plantings.go b/internal/api/plantings.go new file mode 100644 index 0000000..c7dee01 --- /dev/null +++ b/internal/api/plantings.go @@ -0,0 +1,132 @@ +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"` +} + +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, + } +} + +// 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"` + 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 + } + 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, + }, 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) +} diff --git a/internal/api/plantings_test.go b/internal/api/plantings_test.go new file mode 100644 index 0000000..5275377 --- /dev/null +++ b/internal/api/plantings_test.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func plantingPath(id int64) string { return "/api/v1/plantings/" + strconv.FormatInt(id, 10) } +func objectPlantingsPath(id int64) string { return objectPath(id) + "/plantings" } + +func TestPlantingCreatePatchFullDelete(t *testing.T) { + r := authEngine(t, localCfg()) + cookie := registerAndCookie(t, r, "a@example.com") + gid := createGardenAPI(t, r, cookie, "Yard") + + // A plantable bed centered in the garden. + w := doJSON(t, r, http.MethodPost, objectsPath(gid), + map[string]any{"kind": "bed", "xCm": 500, "yCm": 500, "widthCm": 200, "heightCm": 200}, cookie) + oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) + + // A seeded built-in plant to place. + w = doJSON(t, r, http.MethodGet, "/api/v1/plants", nil, cookie) + plantID, _ := firstBuiltinID(t, w.Body.Bytes()) + + // Place a plop → 201 with a derivedCount. + w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), + map[string]any{"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20}, cookie) + if w.Code != http.StatusCreated { + t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String()) + } + pl := decodeMap(t, w.Body.Bytes()) + pid := int64(pl["id"].(float64)) + dc, ok := pl["derivedCount"].(float64) + if !ok || dc < 1 { + t.Errorf("response missing a positive derivedCount: %+v", pl) + } + + // /full includes the plop and its referenced plant. + w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie) + var full struct { + Plantings []map[string]any `json:"plantings"` + Plants []map[string]any `json:"plants"` + } + if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil { + t.Fatalf("decode full: %v", err) + } + if len(full.Plantings) != 1 || len(full.Plants) != 1 { + t.Fatalf("full = %d plantings / %d plants, want 1 / 1", len(full.Plantings), len(full.Plants)) + } + + // Soft-remove (clear-bed style) → it leaves /full. + w = doJSON(t, r, http.MethodPatch, plantingPath(pid), map[string]any{"removedAt": "2030-06-01", "version": 1}, cookie) + if w.Code != http.StatusOK { + t.Fatalf("soft-remove: status %d, body %s", w.Code, w.Body.String()) + } + w = doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie) + full.Plantings = nil + json.Unmarshal(w.Body.Bytes(), &full) + if len(full.Plantings) != 0 { + t.Errorf("removed plop still in /full: %d", len(full.Plantings)) + } + + // Hard delete → 204. + if w := doJSON(t, r, http.MethodDelete, plantingPath(pid), nil, cookie); w.Code != http.StatusNoContent { + t.Errorf("delete planting = %d, want 204", w.Code) + } +} + +func TestPlantingIntoNonPlantableIsRejected(t *testing.T) { + r := authEngine(t, localCfg()) + cookie := registerAndCookie(t, r, "a@example.com") + gid := createGardenAPI(t, r, cookie, "Yard") + + // A tree is not plantable. + w := doJSON(t, r, http.MethodPost, objectsPath(gid), + map[string]any{"kind": "tree", "xCm": 500, "yCm": 500, "widthCm": 100, "heightCm": 100}, cookie) + oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64)) + + w = doJSON(t, r, http.MethodGet, "/api/v1/plants", nil, cookie) + plantID, _ := firstBuiltinID(t, w.Body.Bytes()) + + w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), + map[string]any{"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 10}, cookie) + if w.Code != http.StatusBadRequest { + t.Errorf("plant into tree = %d, want 400", w.Code) + } +} + +func TestPlantingsRequireAuth(t *testing.T) { + r := authEngine(t, localCfg()) + if w := doJSON(t, r, http.MethodPost, "/api/v1/objects/1/plantings", map[string]any{"plantId": 1}, nil); w.Code != http.StatusUnauthorized { + t.Errorf("unauthenticated create = %d, want 401", w.Code) + } + if w := doJSON(t, r, http.MethodDelete, "/api/v1/plantings/1", nil, nil); w.Code != http.StatusUnauthorized { + t.Errorf("unauthenticated delete = %d, want 401", w.Code) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 45c2077..7d1d2af 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -186,4 +186,10 @@ type Planting struct { Version int64 `json:"version"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` + + // DerivedCount is the plant count implied by the plop's area and the plant's + // mature spacing: max(1, round(π·r² / spacing²)). It is computed by the + // service, never persisted (the store scan leaves it 0). The effective count + // a client shows is Count when non-nil, else DerivedCount. + DerivedCount int `json:"derivedCount"` } diff --git a/internal/service/objects.go b/internal/service/objects.go index befa558..2e27bb0 100644 --- a/internal/service/objects.go +++ b/internal/service/objects.go @@ -69,8 +69,8 @@ type ObjectPatch struct { } // FullGarden is the one-shot editor payload: the garden plus everything drawn in -// it. Plantings/Plants stay empty until #14 populates plantings; the shape is -// fixed now so the frontend types against it once. +// it — objects, active plantings (each with its DerivedCount filled in), and the +// plants those plantings reference. type FullGarden struct { Garden *domain.Garden `json:"garden"` Objects []domain.GardenObject `json:"objects"` @@ -176,6 +176,15 @@ func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*Ful if err != nil { return nil, err } + // Fill each plop's DerivedCount from its plant's spacing (referenced plants + // are already loaded, so this is a map lookup rather than N queries). + spacing := make(map[int64]float64, len(plants)) + for _, pl := range plants { + spacing[pl.ID] = pl.SpacingCM + } + for i := range plantings { + plantings[i].DerivedCount = derivedCount(plantings[i].RadiusCM, spacing[plantings[i].PlantID]) + } return &FullGarden{Garden: g, Objects: objects, Plantings: plantings, Plants: plants}, nil } diff --git a/internal/service/plantings.go b/internal/service/plantings.go new file mode 100644 index 0000000..7396d82 --- /dev/null +++ b/internal/service/plantings.go @@ -0,0 +1,272 @@ +package service + +import ( + "context" + "errors" + "math" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +// dateLayout is the 'YYYY-MM-DD' format plantings store planted_at/removed_at in. +const dateLayout = "2006-01-02" + +const ( + maxPlantingLabelLen = 200 + maxRadiusCM = 10_000 // 100 m — a generous plop ceiling + maxExplicitCount = 1_000_000 // sanity cap on a manual count override +) + +// derivedCount is the plant count implied by a plop's area and the plant's +// mature spacing: max(1, round(π·r² / spacing²)). It is the single source of the +// formula (also feeds #15's display and #19's FillRegion). A non-positive or +// non-finite radius/spacing collapses to the floor of 1; the result is capped at +// maxExplicitCount so a huge radius / tiny spacing can't yield an absurd (or, on +// a 32-bit int, overflowing) value — the same ceiling a manual override honors. +func derivedCount(radiusCM, spacingCM float64) int { + if radiusCM <= 0 || spacingCM <= 0 || !isFinite(radiusCM) || !isFinite(spacingCM) { + return 1 + } + area := math.Pi * radiusCM * radiusCM + n := int(math.Round(area / (spacingCM * spacingCM))) + if n < 1 { + return 1 + } + if n > maxExplicitCount { + return maxExplicitCount + } + return n +} + +// PlantingInput is the payload for placing a plop. Count nil = derived; PlantedAt +// nil defaults to today. +type PlantingInput struct { + PlantID int64 + XCM float64 + YCM float64 + RadiusCM float64 + Count *int + Label *string + PlantedAt *string +} + +// PlantingPatch is a partial update. The nullable fields (Count/Label/PlantedAt/ +// RemovedAt) use a Set* flag to tell "clear to NULL" from "leave unchanged". +// Setting RemovedAt is how "clear bed"/soft-remove works; clearing it un-removes. +type PlantingPatch struct { + PlantID *int64 + XCM *float64 + YCM *float64 + RadiusCM *float64 + SetCount bool + Count *int + SetLabel bool + Label *string + SetPlantedAt bool + PlantedAt *string + SetRemovedAt bool + RemovedAt *string +} + +// CreatePlanting places a plop in a plantable object the actor can edit. +func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, in PlantingInput) (*domain.Planting, error) { + o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) + if err != nil { + return nil, err + } + if !o.Plantable { + return nil, domain.ErrInvalidInput // trees/paths/structures can't hold plants + } + plant, err := s.visiblePlant(ctx, actorID, in.PlantID) + if err != nil { + return nil, err + } + + p := &domain.Planting{ + ObjectID: objectID, + PlantID: in.PlantID, + XCM: in.XCM, + YCM: in.YCM, + RadiusCM: in.RadiusCM, + Count: in.Count, + Label: trimStringPtr(in.Label), + PlantedAt: in.PlantedAt, + } + if p.PlantedAt == nil { + today := s.now().UTC().Format(dateLayout) + p.PlantedAt = &today + } + if err := finalizePlanting(p, o, true); err != nil { + return nil, err + } + created, err := s.store.CreatePlanting(ctx, p) + if err != nil { + return nil, err + } + created.DerivedCount = derivedCount(created.RadiusCM, plant.SpacingCM) + return created, nil +} + +// UpdatePlanting applies a partial, version-guarded patch to a plop in an object +// the actor can edit. On a version mismatch it returns (current, ErrVersionConflict). +func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64, patch PlantingPatch, version int64) (*domain.Planting, error) { + pl, err := s.store.GetPlanting(ctx, plantingID) + if err != nil { + return nil, err // ErrNotFound + } + o, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor) + if err != nil { + return nil, err + } + + applyPlantingPatch(pl, patch) + // The plant may have changed; the (possibly new) plant must be visible. + plant, err := s.visiblePlant(ctx, actorID, pl.PlantID) + if err != nil { + return nil, err + } + // Only re-check the object bounds when the position is actually being moved. + // A plop left outside its object's bounds by a later object resize must still + // be editable (radius, dates, soft-remove) — otherwise it becomes a row you + // can neither fix nor remove. + checkBounds := patch.XCM != nil || patch.YCM != nil + if err := finalizePlanting(pl, o, checkBounds); err != nil { + return nil, err + } + pl.Version = version + + updated, err := s.store.UpdatePlanting(ctx, pl) + if err != nil { + if errors.Is(err, domain.ErrVersionConflict) && updated != nil { + // Enrich the current row with its own plant's derived count. + s.enrichDerived(ctx, updated) + } + return updated, err + } + updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM) + return updated, nil +} + +// DeletePlanting hard-deletes a plop in an object the actor can edit. (Soft +// removal — "clear bed" / harvested — sets removed_at via UpdatePlanting.) +func (s *Service) DeletePlanting(ctx context.Context, actorID, plantingID int64) error { + pl, err := s.store.GetPlanting(ctx, plantingID) + if err != nil { + return err + } + if _, _, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor); err != nil { + return err + } + return s.store.DeletePlanting(ctx, plantingID) +} + +// visiblePlant loads a plant the actor may reference in a planting: a built-in or +// one the actor owns. An unknown plant or another user's plant is an invalid +// reference (ErrInvalidInput) rather than leaking existence. +func (s *Service) visiblePlant(ctx context.Context, actorID, plantID int64) (*domain.Plant, error) { + p, err := s.store.GetPlant(ctx, plantID) + if errors.Is(err, domain.ErrNotFound) { + return nil, domain.ErrInvalidInput + } + if err != nil { + return nil, err + } + if p.OwnerID != nil && *p.OwnerID != actorID { + return nil, domain.ErrInvalidInput + } + return p, nil +} + +// enrichDerived best-effort sets p.DerivedCount from its plant's spacing (used +// when we don't already hold the plant, e.g. a conflict's current row). +func (s *Service) enrichDerived(ctx context.Context, p *domain.Planting) { + plant, err := s.store.GetPlant(ctx, p.PlantID) + if err == nil { + p.DerivedCount = derivedCount(p.RadiusCM, plant.SpacingCM) + } +} + +// applyPlantingPatch mutates pl with each provided patch field. +func applyPlantingPatch(pl *domain.Planting, p PlantingPatch) { + if p.PlantID != nil { + pl.PlantID = *p.PlantID + } + if p.XCM != nil { + pl.XCM = *p.XCM + } + if p.YCM != nil { + pl.YCM = *p.YCM + } + if p.RadiusCM != nil { + pl.RadiusCM = *p.RadiusCM + } + if p.SetCount { + pl.Count = p.Count + } + if p.SetLabel { + pl.Label = trimStringPtr(p.Label) + } + if p.SetPlantedAt { + pl.PlantedAt = p.PlantedAt + } + if p.SetRemovedAt { + pl.RemovedAt = p.RemovedAt + } +} + +// finalizePlanting validates a built/merged plop against its parent object. +// Shared by create and update so both enforce the same invariants. checkBounds +// gates the center-in-object-bounds test: create always checks it, update only +// when the position is being moved (so a resize that orphans a plop outside the +// shrunken object doesn't block editing/removing it). +func finalizePlanting(p *domain.Planting, o *domain.GardenObject, checkBounds bool) error { + if !isFinite(p.RadiusCM) || p.RadiusCM <= 0 || p.RadiusCM > maxRadiusCM { + return domain.ErrInvalidInput + } + if !isFinite(p.XCM) || !isFinite(p.YCM) { + return domain.ErrInvalidInput + } + // Loose bounds: the plop's CENTER must sit within the object's unrotated + // local bounds (origin at object center); the radius may overhang. + if checkBounds && (math.Abs(p.XCM) > o.WidthCM/2 || math.Abs(p.YCM) > o.HeightCM/2) { + return domain.ErrInvalidInput + } + if p.Count != nil && (*p.Count < 1 || *p.Count > maxExplicitCount) { + return domain.ErrInvalidInput + } + if p.Label != nil && len(*p.Label) > maxPlantingLabelLen { + return domain.ErrInvalidInput + } + if !validDatePtr(p.PlantedAt) || !validDatePtr(p.RemovedAt) { + return domain.ErrInvalidInput + } + // A plop can't be removed before it was planted (nonsensical interval). + if p.PlantedAt != nil && p.RemovedAt != nil { + planted, _ := time.Parse(dateLayout, *p.PlantedAt) + removed, _ := time.Parse(dateLayout, *p.RemovedAt) + if removed.Before(planted) { + return domain.ErrInvalidInput + } + } + return nil +} + +// validDatePtr reports whether a nil-or-'YYYY-MM-DD' date pointer is acceptable. +func validDatePtr(s *string) bool { + if s == nil { + return true + } + _, err := time.Parse(dateLayout, *s) + return err == nil +} + +// trimStringPtr trims a *string, preserving nil (distinct from empty). +func trimStringPtr(s *string) *string { + if s == nil { + return nil + } + t := strings.TrimSpace(*s) + return &t +} diff --git a/internal/service/plantings_test.go b/internal/service/plantings_test.go new file mode 100644 index 0000000..437511b --- /dev/null +++ b/internal/service/plantings_test.go @@ -0,0 +1,378 @@ +package service + +import ( + "context" + "errors" + "testing" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +// seedBed creates a plantable 200×200 bed centered in a 1000×1000 garden. +func seedBed(t *testing.T, s *Service, owner, gardenID int64) *domain.GardenObject { + t.Helper() + o, err := s.CreateObject(context.Background(), owner, gardenID, ObjectInput{ + Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200, + }) + if err != nil { + t.Fatalf("seed bed: %v", err) + } + return o +} + +// seedOwnPlant creates a custom plant owned by owner with the given spacing. +func seedOwnPlant(t *testing.T, s *Service, owner int64, spacingCM float64) *domain.Plant { + t.Helper() + p, err := s.CreatePlant(context.Background(), owner, PlantInput{ + Name: "Testroot", Category: domain.CategoryHerb, SpacingCM: spacingCM, Color: "#4a7c3f", Icon: "🌿", + }) + if err != nil { + t.Fatalf("seed plant: %v", err) + } + return p +} + +func TestDerivedCountFormula(t *testing.T) { + cases := []struct { + r, sp float64 + want int + }{ + {0.1, 10, 1}, // tiny radius floors to 1 + {0, 10, 1}, // zero radius → 1 + {10, 0, 1}, // zero spacing → 1 + {10, 10, 3}, // π·100/100 = 3.14 → 3 + {50, 10, 79}, // π·2500/100 = 78.5 → 79 + {30, 15, 13}, // π·900/225 = 12.57 → 13 + } + for i, c := range cases { + if got := derivedCount(c.r, c.sp); got != c.want { + t.Errorf("case %d: derivedCount(%v, %v) = %d, want %d", i, c.r, c.sp, got, c.want) + } + } +} + +func TestDerivedCountCapped(t *testing.T) { + // A huge radius with a tiny spacing would produce an absurd (32-bit + // overflowing) count; it's capped at the same ceiling as a manual override. + if got := derivedCount(10_000, 0.1); got != maxExplicitCount { + t.Errorf("derivedCount(10000, 0.1) = %d, want cap %d", got, maxExplicitCount) + } +} + +func TestCreatePlantingDefaultsAndDerivedCount(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + + pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 10, YCM: -20, RadiusCM: 10, + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if pl.Count != nil { + t.Errorf("count = %v, want nil (derived)", *pl.Count) + } + if pl.DerivedCount != 3 { + t.Errorf("derivedCount = %d, want 3", pl.DerivedCount) + } + if pl.PlantedAt == nil { + t.Error("plantedAt should default to today, got nil") + } + if pl.Version != 1 || pl.ObjectID != bed.ID { + t.Errorf("unexpected planting: %+v", pl) + } +} + +func TestPlantingCountOverrideWins(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + + cnt := 12 + pl, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 50, Count: &cnt, + }) + if err != nil { + t.Fatalf("CreatePlanting: %v", err) + } + if pl.Count == nil || *pl.Count != 12 { + t.Errorf("count override = %v, want 12", pl.Count) + } + // derivedCount is still computed alongside the override (79 for r=50, sp=10). + if pl.DerivedCount != 79 { + t.Errorf("derivedCount = %d, want 79", pl.DerivedCount) + } +} + +func TestUpdatePlantingMoveResizeAndClearCount(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + + cnt := 5 + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, Count: &cnt, + }) + + // Move + grow radius, and clear the count override back to derived. + nx, ny, nr := 30.0, -40.0, 50.0 + updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID, + PlantingPatch{XCM: &nx, YCM: &ny, RadiusCM: &nr, SetCount: true, Count: nil}, pl.Version) + if err != nil { + t.Fatalf("UpdatePlanting: %v", err) + } + if updated.XCM != 30 || updated.YCM != -40 || updated.RadiusCM != 50 { + t.Errorf("move/resize didn't apply: %+v", updated) + } + if updated.Count != nil { + t.Errorf("count should be cleared, got %v", *updated.Count) + } + if updated.DerivedCount != 79 { // r=50, sp=10 + t.Errorf("derivedCount after grow = %d, want 79", updated.DerivedCount) + } + if updated.Version != pl.Version+1 { + t.Errorf("version = %d, want %d", updated.Version, pl.Version+1) + } +} + +func TestPlantingNonPlantableObjectRejected(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + plant := seedOwnPlant(t, s, owner, 10) + + tree, _ := s.CreateObject(context.Background(), owner, g.ID, ObjectInput{ + Kind: domain.KindTree, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, + }) + if _, err := s.CreatePlanting(context.Background(), owner, tree.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("planting into a tree err = %v, want ErrInvalidInput", err) + } +} + +func TestPlantingForeignPlantRejectedBuiltinAllowed(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + bob := seedUser(t, s, "bob@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + + // Bob's custom plant is invisible to owner → invalid reference. + bobPlant := seedOwnPlant(t, s, bob, 10) + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: bobPlant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("foreign plant err = %v, want ErrInvalidInput", err) + } + // An unknown plant id is likewise invalid. + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: 999999, XCM: 0, YCM: 0, RadiusCM: 10, + }); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("unknown plant err = %v, want ErrInvalidInput", err) + } + // A built-in plant is plantable by anyone. + builtins, _ := s.ListPlants(context.Background(), owner) + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: builtins[0].ID, XCM: 0, YCM: 0, RadiusCM: 10, + }); err != nil { + t.Errorf("planting a built-in should work: %v", err) + } +} + +func TestPlantingBoundsCheck(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) // 200×200 → local bounds ±100 + plant := seedOwnPlant(t, s, owner, 10) + + // Center outside the object's local bounds → rejected (radius may overhang, + // but the center may not). + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 150, YCM: 0, RadiusCM: 10, + }); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("out-of-bounds center err = %v, want ErrInvalidInput", err) + } + // On the edge is allowed. + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 100, YCM: -100, RadiusCM: 30, + }); err != nil { + t.Errorf("edge-of-bounds center should be allowed: %v", err) + } + // Non-positive radius rejected. + if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0, + }); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("zero radius err = %v, want ErrInvalidInput", err) + } +} + +func TestPlantingSoftRemoveLeavesFull(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }) + + // It shows in /full, with a derived count. + full, _ := s.GardenFull(context.Background(), owner, g.ID) + if len(full.Plantings) != 1 { + t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings)) + } + if full.Plantings[0].DerivedCount != 3 { + t.Errorf("full derivedCount = %d, want 3", full.Plantings[0].DerivedCount) + } + if len(full.Plants) != 1 { + t.Errorf("full.Plants = %d, want 1 referenced plant", len(full.Plants)) + } + + // Soft-remove → it leaves /full. (On/after today's default planted_at.) + rm := "2030-06-01" + if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, + PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil { + t.Fatalf("soft-remove: %v", err) + } + full, _ = s.GardenFull(context.Background(), owner, g.ID) + if len(full.Plantings) != 0 { + t.Errorf("removed plop still in /full: %d", len(full.Plantings)) + } +} + +func TestPlantingRemovedBeforePlantedRejected(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + + // planted_at defaults to today; a removed_at in the distant past is invalid. + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }) + past := "2000-01-01" + if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, + PlantingPatch{SetRemovedAt: true, RemovedAt: &past}, pl.Version); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("removed-before-planted err = %v, want ErrInvalidInput", err) + } +} + +func TestUpdatePlantingEditableAfterObjectShrinks(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) // 200×200 → local bounds ±100 + plant := seedOwnPlant(t, s, owner, 10) + + // A plop near the bed's edge. + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 90, YCM: 0, RadiusCM: 10, + }) + + // Shrink the bed to 100×100 (local bounds ±50): the plop's center (90) is now + // outside the object. + w, h := 100.0, 100.0 + if _, err := s.UpdateObject(context.Background(), owner, bed.ID, ObjectPatch{WidthCM: &w, HeightCM: &h}, bed.Version); err != nil { + t.Fatalf("shrink bed: %v", err) + } + + // Editing the out-of-bounds plop without moving it (radius here) must still + // work — otherwise it'd be un-removable. + nr := 15.0 + updated, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version) + if err != nil { + t.Fatalf("radius edit on orphaned plop should work: %v", err) + } + // But actually moving it still enforces the new bounds. + outside := 90.0 + if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &outside}, updated.Version); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("moving to out-of-bounds should be rejected: %v", err) + } + // Moving it back inside works. + inside := 10.0 + if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{XCM: &inside}, updated.Version); err != nil { + t.Errorf("moving in-bounds should work: %v", err) + } +} + +func TestPlantingVersionConflict(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }) + + nr := 20.0 + if _, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); err != nil { + t.Fatalf("first update: %v", err) + } + current, err := s.UpdatePlanting(context.Background(), owner, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version) + if !errors.Is(err, domain.ErrVersionConflict) { + t.Fatalf("stale update err = %v, want ErrVersionConflict", err) + } + if current == nil || current.Version != 2 { + t.Fatalf("conflict didn't return current row: %+v", current) + } + if current.DerivedCount < 1 { + t.Errorf("conflict current row should carry a derived count, got %d", current.DerivedCount) + } +} + +func TestPlantingCrossUserIsNotFound(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + bob := seedUser(t, s, "bob@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }) + + nr := 5.0 + if _, err := s.UpdatePlanting(context.Background(), bob, pl.ID, PlantingPatch{RadiusCM: &nr}, pl.Version); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("bob update err = %v, want ErrNotFound", err) + } + if err := s.DeletePlanting(context.Background(), bob, pl.ID); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("bob delete err = %v, want ErrNotFound", err) + } + // Bob can't place into owner's bed either. + if _, err := s.CreatePlanting(context.Background(), bob, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("bob create err = %v, want ErrNotFound", err) + } +} + +func TestDeletePlanting(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + pl, _ := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ + PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10, + }) + + if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil { + t.Fatalf("DeletePlanting: %v", err) + } + full, _ := s.GardenFull(context.Background(), owner, g.ID) + if len(full.Plantings) != 0 { + t.Errorf("planting still present after delete: %d", len(full.Plantings)) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index b33cedd..2c7a0d6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -20,9 +20,11 @@ import ( "gitea.stevedudenhoeffer.com/steve/pansy/internal/store" ) -// timeLayout is the ISO-8601 UTC format used for every timestamp pansy stores. -// It matches the schema's strftime('%Y-%m-%dT%H:%M:%SZ') so string comparison -// (e.g. session expiry) is equivalent to time comparison. +// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy +// stores (created_at/updated_at/expires_at). It matches the schema's +// strftime('%Y-%m-%dT%H:%M:%SZ') so string comparison (e.g. session expiry) is +// equivalent to time comparison. Date-only fields (planting planted_at/ +// removed_at) use dateLayout instead. const timeLayout = "2006-01-02T15:04:05Z" // sessionTTL is how long a session lives from its last use (sliding expiry). diff --git a/internal/store/plantings.go b/internal/store/plantings.go index 29dbd94..a60f430 100644 --- a/internal/store/plantings.go +++ b/internal/store/plantings.go @@ -2,6 +2,8 @@ package store import ( "context" + "database/sql" + "errors" "fmt" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" @@ -26,8 +28,7 @@ func scanPlanting(s scanner) (*domain.Planting, error) { // ListActivePlantingsForGarden returns every currently-planted plop (removed_at // IS NULL) across all objects in a garden — the editor's one-shot load. Always a -// non-nil slice (empty until plantings CRUD lands in #14). Plop CRUD itself is -// #14; this is only the /full read side. +// non-nil slice. The service fills each row's DerivedCount; this is the raw read. func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ([]domain.Planting, error) { rows, err := d.sql.QueryContext(ctx, `SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl @@ -54,3 +55,78 @@ func (d *DB) ListActivePlantingsForGarden(ctx context.Context, gardenID int64) ( } return plantings, nil } + +// GetPlanting returns the planting with the given id, or domain.ErrNotFound. +func (d *DB) GetPlanting(ctx context.Context, id int64) (*domain.Planting, error) { + p, err := scanPlanting(d.sql.QueryRowContext(ctx, + `SELECT `+plantingColumns+` FROM plantings WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("store: get planting: %w", err) + } + return p, nil +} + +// CreatePlanting inserts a plop (fields already validated by the service) and +// returns the stored row. removed_at is always NULL on create — a new plop is +// active; "clear bed" sets removed_at later via UpdatePlanting. +func (d *DB) CreatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) { + created, err := scanPlanting(d.sql.QueryRowContext(ctx, + `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm, count, label, planted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING `+plantingColumns, + p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, + )) + if err != nil { + return nil, fmt.Errorf("store: insert planting: %w", err) + } + return created, nil +} + +// UpdatePlanting applies a version-guarded update of all mutable columns (the +// service merges partial patches first). Returns the updated row, or +// (current row, ErrVersionConflict) / ErrNotFound — the same contract as the +// other mutable resources. +func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) { + updated, err := scanPlanting(d.sql.QueryRowContext(ctx, + `UPDATE plantings + SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?, + planted_at = ?, removed_at = ?, + version = version + 1, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? AND version = ? + RETURNING `+plantingColumns, + p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, + p.ID, p.Version, + )) + if errors.Is(err, sql.ErrNoRows) { + current, gerr := d.GetPlanting(ctx, p.ID) + if gerr != nil { + return nil, gerr + } + return current, domain.ErrVersionConflict + } + if err != nil { + return nil, fmt.Errorf("store: update planting: %w", err) + } + return updated, nil +} + +// DeletePlanting hard-deletes a plop (for mistakes; "removed/harvested" flows set +// removed_at instead). Returns domain.ErrNotFound if no row was deleted. +func (d *DB) DeletePlanting(ctx context.Context, id int64) error { + res, err := d.sql.ExecContext(ctx, `DELETE FROM plantings WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete planting: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("store: planting delete rows: %w", err) + } + if n == 0 { + return domain.ErrNotFound + } + return nil +} diff --git a/internal/store/plants.go b/internal/store/plants.go index 14f4744..e1b53ee 100644 --- a/internal/store/plants.go +++ b/internal/store/plants.go @@ -26,8 +26,8 @@ func scanPlant(s scanner) (*domain.Plant, error) { // ListReferencedPlants returns the distinct plants used by a garden's active // plantings — the catalog subset the editor needs to render them. Always a -// non-nil slice (empty until plantings exist, #14). Full plant CRUD/seeding is -// #12; this is only the /full read side. +// non-nil slice (empty when the garden has no active plantings). This is the +// /full read side; full plant CRUD/seeding lives in plants.go / plantings.go. func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) { rows, err := d.sql.QueryContext(ctx, `SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p