happens. Notes get written standing in the garden holding a phone, usually one-handed — if it takes more than a couple of taps from looking at a bed to typing a sentence, the log stays empty. So the journal is a tab in the rail #49 settled, and selecting a bed puts a "📝 Add note" button in the inspector that scopes the journal to that bed and switches to it. The composer is already open in there rather than behind an "add" button, which makes it two taps from a selected bed to typing. The observed date defaults to today in the VIEWER's timezone, not UTC — "today" means the day you are standing in the garden — and stays editable, because you write up Saturday's observations on Sunday. Discoverability is the other half. A log you have to remember exists is a log nobody reads, so the inspector button carries the note count for that bed and the Journal tab carries the garden's total: a garden with a season's notes in it no longer looks identical to an empty one. Those counts come from their own endpoint, deliberately — the indicator has to work while the journal panel is closed, which is exactly when the entry list hasn't loaded. Permissions match the service: anyone who can edit the garden can write, the author can edit their own text, and the author or the garden owner can delete. A viewer sees the entries and the count but no composer. The empty state says what the journal is for rather than just "no entries", since it starts empty for a whole season's worth of gardens and the reason to start is the thing worth saying. Closes #53 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
164 lines
4.8 KiB
Go
164 lines
4.8 KiB
Go
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})
|
|
}
|
|
|
|
// getJournalCounts powers the "there are notes about this bed" indicator, which
|
|
// has to work while the journal panel is closed — exactly when nothing else has
|
|
// loaded the entries. Key "0" is the garden itself.
|
|
func (h *handlers) getJournalCounts(c *gin.Context) {
|
|
gardenID, ok := parseIDParam(c, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
|
|
if err != nil {
|
|
writeServiceError(c, err)
|
|
return
|
|
}
|
|
// JSON object keys are strings; the client parses them back to ids.
|
|
out := make(map[string]int, len(counts))
|
|
for id, n := range counts {
|
|
out[strconv.FormatInt(id, 10)] = n
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"counts": out})
|
|
}
|
|
|
|
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
|
|
}
|