Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
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
|
|
}
|