Grow journal: time-stamped notes on gardens, beds and plantings (#52)
"Take notes through the season on each bed and plant." There is already free-text notes on gardens, objects and plants, but it is a single mutable field: writing "powdery mildew on the west bed" overwrites what you wrote in June. The distinction worth keeping is that notes says what this thing IS, while a journal entry says what HAPPENED, and when. Both stay. garden_id is NOT NULL even when an entry is about a bed or a single plop, and that is the whole trick: permission checks reuse requireGardenRole unchanged and no second ACL path is invented. object_id/planting_id narrow the target; the garden always anchors it. Because the garden anchors permission, the target is checked to actually live in that garden — otherwise a valid object id from someone else's garden would be storable here and then leak through the list read. A plop-level entry that also names an object must name the right one, or the two filters would disagree about what an entry is about. observed_at is distinct from created_at: you write up Saturday's observations on Sunday, and Saturday is the date that matters. Listing orders by observation, newest first, with id breaking ties inside a day. Roles follow the existing shape. Editors write, viewers read, strangers get ErrNotFound. An author may edit their own entries; the garden owner may DELETE any entry in their garden but may not edit one — rewriting somebody else's observation under their name is a different act from removing it. Deliberately not wired into the revision history, per the decision on #52: entries are already append-shaped and individually versioned, and undoing a note is just deleting it. There's a test asserting journal writes produce no change sets, so that decision can't quietly reverse itself later. Closes #52 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user