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 }