Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #67.
This commit is contained in:
@@ -90,6 +90,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// plop, so it inherits the ordinary garden-role check.
|
||||
gardens.GET("/:id/journal", h.listJournal)
|
||||
gardens.POST("/:id/journal", h.createJournalEntry)
|
||||
gardens.GET("/:id/journal/counts", h.getJournalCounts)
|
||||
|
||||
// Sharing (owner-managed; a recipient may remove their own share).
|
||||
gardens.GET("/:id/shares", h.listShares)
|
||||
|
||||
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
|
||||
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 {
|
||||
|
||||
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
|
||||
t.Errorf("stranger delete: status %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
|
||||
// work while the journal panel is closed, so it's its own endpoint.
|
||||
func TestJournalCountsAPI(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))
|
||||
|
||||
counts := func() map[string]any {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
|
||||
return c
|
||||
}
|
||||
if len(counts()) != 0 {
|
||||
t.Errorf("a garden with no entries should report no counts")
|
||||
}
|
||||
|
||||
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
|
||||
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
|
||||
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
|
||||
|
||||
c := counts()
|
||||
// Key "0" is the garden itself; anything else is an object id.
|
||||
if c["0"] != float64(1) {
|
||||
t.Errorf("garden-level count = %v, want 1", c["0"])
|
||||
}
|
||||
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
|
||||
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
|
||||
}
|
||||
|
||||
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous counts: status %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
|
||||
return entries, false, nil
|
||||
}
|
||||
|
||||
// JournalCounts returns how many entries a garden holds per object (key 0 for
|
||||
// entries about the garden itself), for an actor who can at least view it.
|
||||
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
|
||||
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.store.JournalCounts(ctx, gardenID)
|
||||
}
|
||||
|
||||
// CreateJournalEntry writes an entry against a garden the actor can edit.
|
||||
//
|
||||
// An entry may target the garden, one of its beds, or one plop in it — and the
|
||||
|
||||
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// JournalCounts returns how many entries a garden holds, keyed by the object
|
||||
// they are about — key 0 for entries about the garden itself.
|
||||
//
|
||||
// A count per object rather than a flag, and one query rather than one per bed:
|
||||
// the indicator has to be available when the journal panel is closed, which is
|
||||
// exactly when nothing else has loaded the entries.
|
||||
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
|
||||
rows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT COALESCE(object_id, 0), COUNT(*)
|
||||
FROM journal_entries WHERE garden_id = ?
|
||||
GROUP BY COALESCE(object_id, 0)`,
|
||||
gardenID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: journal counts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var objectID int64
|
||||
var n int
|
||||
if err := rows.Scan(&objectID, &n); err != nil {
|
||||
return nil, fmt.Errorf("store: scan journal count: %w", err)
|
||||
}
|
||||
counts[objectID] = n
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
|
||||
// Same contract as every other mutable resource. The target (garden/object/
|
||||
// planting) and the author are immutable: an entry is a record of an observation,
|
||||
|
||||
Reference in New Issue
Block a user