---
phase: 04-ux-improvements
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pkg/diunwebhook/store.go
- pkg/diunwebhook/sqlite_store.go
- pkg/diunwebhook/postgres_store.go
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/diunwebhook_test.go
- pkg/diunwebhook/export_test.go
- cmd/diunwebhook/main.go
autonomous: true
requirements:
- BULK-01
- BULK-02
must_haves:
truths:
- "POST /api/updates/acknowledge-all marks all unacknowledged updates and returns the count"
- "POST /api/updates/acknowledge-by-tag marks only unacknowledged updates in the given tag and returns the count"
- "Both endpoints return 200 with {count: 0} when nothing matches (not 404)"
artifacts:
- path: "pkg/diunwebhook/store.go"
provides: "Extended Store interface with AcknowledgeAll and AcknowledgeByTag"
contains: "AcknowledgeAll"
- path: "pkg/diunwebhook/sqlite_store.go"
provides: "SQLiteStore bulk acknowledge implementations"
contains: "func (s *SQLiteStore) AcknowledgeAll"
- path: "pkg/diunwebhook/postgres_store.go"
provides: "PostgresStore bulk acknowledge implementations"
contains: "func (s *PostgresStore) AcknowledgeAll"
- path: "pkg/diunwebhook/diunwebhook.go"
provides: "HTTP handlers for bulk acknowledge endpoints"
contains: "AcknowledgeAllHandler"
- path: "cmd/diunwebhook/main.go"
provides: "Route registration for new endpoints"
contains: "/api/updates/acknowledge-all"
key_links:
- from: "cmd/diunwebhook/main.go"
to: "pkg/diunwebhook/diunwebhook.go"
via: "mux.HandleFunc registration"
pattern: "HandleFunc.*acknowledge"
- from: "pkg/diunwebhook/diunwebhook.go"
to: "pkg/diunwebhook/store.go"
via: "s.store.AcknowledgeAll() and s.store.AcknowledgeByTag()"
pattern: "s\\.store\\.Acknowledge(All|ByTag)"
---
Add backend support for bulk acknowledge operations: acknowledge all pending updates at once, and acknowledge all pending updates within a specific tag group.
Purpose: Enables the frontend (Plan 03) to offer "Dismiss All" and "Dismiss Group" buttons.
Output: Two new Store interface methods, implementations for both SQLite and PostgreSQL, two new HTTP handlers, route registrations, and tests.
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-ux-improvements/04-CONTEXT.md
@.planning/phases/04-ux-improvements/04-RESEARCH.md
From pkg/diunwebhook/store.go:
```go
type Store interface {
UpsertEvent(event DiunEvent) error
GetUpdates() (map[string]UpdateEntry, error)
AcknowledgeUpdate(image string) (found bool, err error)
ListTags() ([]Tag, error)
CreateTag(name string) (Tag, error)
DeleteTag(id int) (found bool, err error)
AssignTag(image string, tagID int) error
UnassignTag(image string) error
TagExists(id int) (bool, error)
}
```
From pkg/diunwebhook/sqlite_store.go (AcknowledgeUpdate pattern to follow):
```go
func (s *SQLiteStore) AcknowledgeUpdate(image string) (found bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
```
From pkg/diunwebhook/postgres_store.go (same method, PostgreSQL dialect):
```go
func (s *PostgresStore) AcknowledgeUpdate(image string) (found bool, err error) {
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE image = $1`, image)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
```
From pkg/diunwebhook/diunwebhook.go (DismissHandler pattern to follow):
```go
func (s *Server) DismissHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// ...
}
```
Current route registration order in cmd/diunwebhook/main.go:
```go
mux.HandleFunc("/api/updates/", srv.DismissHandler)
mux.HandleFunc("/api/updates", srv.UpdatesHandler)
```
Task 1: Extend Store interface and implement AcknowledgeAll + AcknowledgeByTag with store-level tests
pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go, pkg/diunwebhook/postgres_store.go, pkg/diunwebhook/diunwebhook_test.go, pkg/diunwebhook/export_test.go
- pkg/diunwebhook/store.go
- pkg/diunwebhook/sqlite_store.go
- pkg/diunwebhook/postgres_store.go
- pkg/diunwebhook/diunwebhook_test.go
- pkg/diunwebhook/export_test.go
- Test 1: AcknowledgeAll on empty DB returns count=0, no error
- Test 2: AcknowledgeAll with 3 unacknowledged updates returns count=3; subsequent GetUpdates shows all acknowledged
- Test 3: AcknowledgeAll with 2 unacknowledged + 1 already acknowledged returns count=2
- Test 4: AcknowledgeByTag with valid tag_id returns count of matching unacknowledged updates in that tag
- Test 5: AcknowledgeByTag with non-existent tag_id returns count=0, no error
- Test 6: AcknowledgeByTag does not affect updates in other tags or untagged updates
TDD approach -- write tests first, then implement:
1. Add test helper exports to `export_test.go`:
```go
func (s *Server) TestAcknowledgeAll() (int, error) {
return s.Store().AcknowledgeAll()
}
func (s *Server) TestAcknowledgeByTag(tagID int) (int, error) {
return s.Store().AcknowledgeByTag(tagID)
}
```
(Add a `Store() Store` accessor method on Server if not already present, or access the store field directly via an existing test export pattern.)
2. Write store-level tests in `diunwebhook_test.go` following existing `Test_` convention:
- `TestAcknowledgeAll_Empty`: create server, call TestAcknowledgeAll, assert count=0, no error
- `TestAcknowledgeAll_AllUnacknowledged`: upsert 3 events via TestUpsertEvent, call TestAcknowledgeAll, assert count=3, then call GetUpdates and verify all have acknowledged=true
- `TestAcknowledgeAll_MixedState`: upsert 3 events, acknowledge 1 via existing dismiss, call TestAcknowledgeAll, assert count=2
- `TestAcknowledgeByTag_MatchingTag`: upsert 2 events, create tag, assign both to tag, call TestAcknowledgeByTag(tagID), assert count=2
- `TestAcknowledgeByTag_NonExistentTag`: call TestAcknowledgeByTag(9999), assert count=0, no error
- `TestAcknowledgeByTag_OnlyAffectsTargetTag`: upsert 3 events, create 2 tags, assign 2 events to tag1 and 1 to tag2, call TestAcknowledgeByTag(tag1.ID), assert count=2, verify tag2's event is still unacknowledged via GetUpdates
Run tests -- they must FAIL (RED) since methods don't exist yet.
3. Add two methods to the Store interface in `store.go` (per D-01):
```go
AcknowledgeAll() (count int, err error)
AcknowledgeByTag(tagID int) (count int, err error)
```
4. Implement in `sqlite_store.go` (following AcknowledgeUpdate pattern with mutex):
- `AcknowledgeAll`: `s.mu.Lock()`, `s.db.Exec("UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL")`, return `int(RowsAffected())`
- `AcknowledgeByTag`: `s.mu.Lock()`, `s.db.Exec("UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL AND image IN (SELECT image FROM tag_assignments WHERE tag_id = ?)", tagID)`, return `int(RowsAffected())`
5. Implement in `postgres_store.go` (no mutex, use NOW() and $1 positional param):
- `AcknowledgeAll`: `s.db.Exec("UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL")`, return `int(RowsAffected())`
- `AcknowledgeByTag`: `s.db.Exec("UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL AND image IN (SELECT image FROM tag_assignments WHERE tag_id = $1)", tagID)`, return `int(RowsAffected())`
6. Run tests again -- they must PASS (GREEN).
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestAcknowledge(All|ByTag)_" ./pkg/diunwebhook/
- store.go contains `AcknowledgeAll() (count int, err error)` in the Store interface
- store.go contains `AcknowledgeByTag(tagID int) (count int, err error)` in the Store interface
- sqlite_store.go contains `func (s *SQLiteStore) AcknowledgeAll() (int, error)`
- sqlite_store.go contains `func (s *SQLiteStore) AcknowledgeByTag(tagID int) (int, error)`
- sqlite_store.go AcknowledgeAll contains `s.mu.Lock()`
- sqlite_store.go AcknowledgeAll contains `WHERE acknowledged_at IS NULL`
- sqlite_store.go AcknowledgeByTag contains `SELECT image FROM tag_assignments WHERE tag_id = ?`
- postgres_store.go contains `func (s *PostgresStore) AcknowledgeAll() (int, error)`
- postgres_store.go contains `func (s *PostgresStore) AcknowledgeByTag(tagID int) (int, error)`
- postgres_store.go AcknowledgeByTag contains `$1` (positional param)
- diunwebhook_test.go contains `TestAcknowledgeAll_Empty`
- diunwebhook_test.go contains `TestAcknowledgeByTag_OnlyAffectsTargetTag`
- `go test -v -run "TestAcknowledge(All|ByTag)_" ./pkg/diunwebhook/` exits 0
Store interface extended with 2 new methods; both SQLiteStore and PostgresStore compile and implement the interface; 6 store-level tests pass
Task 2: Add HTTP handlers, route registration, and handler tests for bulk acknowledge endpoints
pkg/diunwebhook/diunwebhook.go, pkg/diunwebhook/diunwebhook_test.go, pkg/diunwebhook/export_test.go, cmd/diunwebhook/main.go
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/diunwebhook_test.go
- pkg/diunwebhook/export_test.go
- cmd/diunwebhook/main.go
- Test: POST /api/updates/acknowledge-all with no updates returns 200 + {"count":0}
- Test: POST /api/updates/acknowledge-all with 2 pending updates returns 200 + {"count":2}
- Test: GET /api/updates/acknowledge-all returns 405
- Test: POST /api/updates/acknowledge-by-tag with valid tag_id returns 200 + {"count":N}
- Test: POST /api/updates/acknowledge-by-tag with tag_id=0 returns 400
- Test: POST /api/updates/acknowledge-by-tag with missing body returns 400
- Test: POST /api/updates/acknowledge-by-tag with non-existent tag returns 200 + {"count":0}
- Test: GET /api/updates/acknowledge-by-tag returns 405
1. Add `AcknowledgeAllHandler` to `diunwebhook.go` (per D-02):
```go
func (s *Server) AcknowledgeAllHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
count, err := s.store.AcknowledgeAll()
if err != nil {
log.Printf("AcknowledgeAllHandler: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"count": count})
}
```
2. Add `AcknowledgeByTagHandler` to `diunwebhook.go` (per D-02):
```go
func (s *Server) AcknowledgeByTagHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct {
TagID int `json:"tag_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.TagID <= 0 {
http.Error(w, "bad request: tag_id required", http.StatusBadRequest)
return
}
count, err := s.store.AcknowledgeByTag(req.TagID)
if err != nil {
log.Printf("AcknowledgeByTagHandler: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"count": count})
}
```
3. Register routes in `main.go` -- CRITICAL: new specific paths BEFORE the existing `/api/updates/` subtree pattern:
```go
mux.HandleFunc("/api/updates/acknowledge-all", srv.AcknowledgeAllHandler)
mux.HandleFunc("/api/updates/acknowledge-by-tag", srv.AcknowledgeByTagHandler)
mux.HandleFunc("/api/updates/", srv.DismissHandler) // existing -- must remain after
mux.HandleFunc("/api/updates", srv.UpdatesHandler) // existing
```
4. Add test helper to `export_test.go`:
```go
func (s *Server) TestCreateTag(name string) (Tag, error) {
return s.store.CreateTag(name)
}
func (s *Server) TestAssignTag(image string, tagID int) error {
return s.store.AssignTag(image, tagID)
}
```
5. Write handler tests in `diunwebhook_test.go` following the existing `Test_` naming convention. Use `NewTestServer()` for each test. Setup: use `TestUpsertEvent` to create events, `TestCreateTag` + `TestAssignTag` to setup tag assignments.
6. Also add the Vite dev proxy for the two new endpoints in `frontend/vite.config.ts` -- NOT needed, the existing proxy config already proxies all `/api` requests to `:8080`.
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestAcknowledge(All|ByTag)Handler" ./pkg/diunwebhook/
- diunwebhook.go contains `func (s *Server) AcknowledgeAllHandler(`
- diunwebhook.go contains `func (s *Server) AcknowledgeByTagHandler(`
- diunwebhook.go AcknowledgeAllHandler contains `r.Method != http.MethodPost`
- diunwebhook.go AcknowledgeByTagHandler contains `http.MaxBytesReader`
- diunwebhook.go AcknowledgeByTagHandler contains `req.TagID <= 0`
- main.go contains `"/api/updates/acknowledge-all"` BEFORE `"/api/updates/"`
- main.go contains `"/api/updates/acknowledge-by-tag"` BEFORE `"/api/updates/"`
- diunwebhook_test.go contains `TestAcknowledgeAllHandler_Empty`
- diunwebhook_test.go contains `TestAcknowledgeByTagHandler`
- `go test -run "TestAcknowledge" ./pkg/diunwebhook/` exits 0
- `go vet ./...` exits 0
Both bulk acknowledge endpoints respond correctly; all new tests pass; route order verified
```bash
cd /home/jean-luc-makiola/Development/projects/DiunDashboard
go build ./...
go vet ./...
go test -v -run "TestAcknowledge" ./pkg/diunwebhook/
go test -v ./pkg/diunwebhook/ # all existing tests still pass
```
- Store interface has 11 methods (9 existing + 2 new)
- Both SQLiteStore and PostgresStore implement all 11 methods
- POST /api/updates/acknowledge-all returns 200 + {"count": N}
- POST /api/updates/acknowledge-by-tag returns 200 + {"count": N}
- All existing tests continue to pass
- Route registration order prevents DismissHandler from shadowing new endpoints