Grow journal: time-stamped notes on gardens, beds and plantings (#52) (#65)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #65.
This commit is contained in:
2026-07-21 05:40:17 +00:00
committed by steve
parent 78a04672b5
commit b96ca1ec0a
9 changed files with 1101 additions and 0 deletions
+10
View File
@@ -85,6 +85,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
gardens.POST("/:id/objects", h.createObject)
// The grow journal hangs off a garden even for entries about one bed or one
// plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
@@ -117,6 +121,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
changeSets := v1.Group("/change-sets", h.requireAuth())
changeSets.POST("/:id/revert", h.revertChangeSet)
// Journal entries are addressed by their own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
journal := v1.Group("/journal", h.requireAuth())
journal.PATCH("/:id", h.updateJournalEntry)
journal.DELETE("/:id", h.deleteJournalEntry)
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
plants := v1.Group("/plants", h.requireAuth())
plants.GET("", h.listPlants)
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Grow journal (#52). Entries hang off a garden even when they're about one bed
// or one plop, which is what lets the permission check be the ordinary garden
// role check with nothing new invented.
// journalResponse is the body of GET /gardens/:id/journal.
type journalResponse struct {
Entries []domain.JournalEntry `json:"entries"`
HasMore bool `json:"hasMore"`
}
// journalCreateRequest is the body for POST /gardens/:id/journal. observedAt
// defaults to today when omitted — the common case is writing about now.
type journalCreateRequest struct {
ObjectID *int64 `json:"objectId"`
PlantingID *int64 `json:"plantingId"`
Body string `json:"body" binding:"required"`
ObservedAt *string `json:"observedAt"`
}
// journalUpdateRequest is the body for PATCH /journal/:id. Only the text and the
// date it describes are editable; an entry's target and author are fixed.
type journalUpdateRequest struct {
Body *string `json:"body"`
ObservedAt *string `json:"observedAt"`
Version int64 `json:"version" binding:"required"`
}
func (h *handlers) listJournal(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
q := service.JournalQuery{
Limit: intQuery(c, "limit", 0),
Offset: intQuery(c, "offset", 0),
}
var err error
if q.ObjectID, err = optionalIDQuery(c, "objectId"); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid objectId")
return
}
if q.PlantingID, err = optionalIDQuery(c, "plantingId"); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid plantingId")
return
}
if from := c.Query("from"); from != "" {
q.From = &from
}
if to := c.Query("to"); to != "" {
q.To = &to
}
entries, hasMore, err := h.svc.ListJournal(c.Request.Context(), mustActor(c).ID, gardenID, q)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
}
func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
var req journalCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid entry: a body is required")
return
}
e, err := h.svc.CreateJournalEntry(c.Request.Context(), mustActor(c).ID, gardenID, service.JournalInput{
ObjectID: req.ObjectID, PlantingID: req.PlantingID, Body: req.Body, ObservedAt: req.ObservedAt,
})
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, e)
}
func (h *handlers) updateJournalEntry(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req journalUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: a current version is required")
return
}
e, err := h.svc.UpdateJournalEntry(c.Request.Context(), mustActor(c).ID, id,
service.JournalPatch{Body: req.Body, ObservedAt: req.ObservedAt}, req.Version)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, e)
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, e)
}
func (h *handlers) deleteJournalEntry(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.DeleteJournalEntry(c.Request.Context(), mustActor(c).ID, id); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// optionalIDQuery reads a positive int64 query parameter, or nil when absent.
// A present-but-malformed value is an error rather than a silent nil, so a typo
// doesn't quietly widen a filter to the whole garden.
func optionalIDQuery(c *gin.Context, name string) (*int64, error) {
raw := c.Query(name)
if raw == "" {
return nil, nil
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id < 1 {
return nil, errors.New("invalid id")
}
return &id, nil
}
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"net/http"
"strconv"
"testing"
)
func journalPath(gardenID int64) string {
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/journal"
}
func journalEntryPath(id int64) string {
return "/api/v1/journal/" + strconv.FormatInt(id, 10)
}
// TestJournalCrudAPI walks the whole entry lifecycle over HTTP.
//
// It exists because the service-level tests could not see that PATCH and DELETE
// were never registered on the router: the handlers were written, tested through
// the service, and completely unreachable. Anything addressed by its own id
// needs at least one test that goes through the router.
func TestJournalCrudAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "First frost tonight", "observedAt": "2026-10-12",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
}
entry := decodeMap(t, w.Body.Bytes())
id := int64(entry["id"].(float64))
if entry["observedAt"] != "2026-10-12" || entry["body"] != "First frost tonight" {
t.Errorf("unexpected entry: %+v", entry)
}
// List it back.
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
entries, _ := body["entries"].([]any)
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1: %s", len(entries), w.Body.String())
}
if first := entries[0].(map[string]any); first["authorName"] == "" {
t.Error("author name missing from the list read")
}
// PATCH — the route this test exists for.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "First frost, tomatoes done", "version": entry["version"],
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
}
updated := decodeMap(t, w.Body.Bytes())
if updated["body"] != "First frost, tomatoes done" {
t.Errorf("body = %v", updated["body"])
}
// A stale version conflicts, carrying the current row.
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
"body": "stale", "version": entry["version"],
}, cookie)
if w.Code != http.StatusConflict {
t.Errorf("stale patch: status %d, want 409", w.Code)
}
if current, _ := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); current == nil {
t.Error("409 should carry the current row")
}
// DELETE — likewise.
w = doJSON(t, r, http.MethodDelete, journalEntryPath(id), nil, cookie)
if w.Code != http.StatusNoContent {
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
if entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any); len(entries) != 0 {
t.Errorf("%d entries survived the delete", len(entries))
}
}
// TestJournalFiltersAPI covers the query parameters, including a malformed one.
func TestJournalFiltersAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"body": "garden level", "observedAt": "2026-05-01",
}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
"objectId": objectID, "body": "bed level", "observedAt": "2026-06-01",
}, cookie)
countAt := func(query string) int {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+query, nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list%s: status %d, body %s", query, w.Code, w.Body.String())
}
entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any)
return len(entries)
}
if n := countAt(""); n != 2 {
t.Errorf("unfiltered = %d, want 2", n)
}
if n := countAt("?objectId=" + strconv.FormatInt(objectID, 10)); n != 1 {
t.Errorf("by object = %d, want 1", n)
}
if n := countAt("?from=2026-05-15&to=2026-12-31"); n != 1 {
t.Errorf("by date range = %d, want 1", n)
}
// A malformed filter is an error, not a silently ignored one that widens the
// query back to the whole garden.
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?objectId=abc", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed objectId: status %d, want 400", w.Code)
}
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?from=nonsense", nil, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("malformed from: status %d, want 400", w.Code)
}
}
// TestJournalRequiresAccessAPI — anonymous is 401, a stranger is 404.
func TestJournalRequiresAccessAPI(t *testing.T) {
r := authEngine(t, localCfg())
owner := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, owner, "G")
stranger := registerAndCookie(t, r, "[email protected]")
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous list: status %d, want 401", w.Code)
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger list: status %d, want 404", w.Code)
}
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "nope"}, stranger)
if w.Code != http.StatusNotFound {
t.Errorf("stranger write: status %d, want 404", w.Code)
}
if w := doJSON(t, r, http.MethodDelete, journalEntryPath(1), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger delete: status %d, want 404", w.Code)
}
}