Files
DiunDashboard/.planning/phases/04-ux-improvements/04-01-PLAN.md

318 lines
14 KiB
Markdown

---
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)"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Store interface the executor must extend -->
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)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend Store interface and implement AcknowledgeAll + AcknowledgeByTag in both stores</name>
<files>pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go, pkg/diunwebhook/postgres_store.go</files>
<read_first>
- pkg/diunwebhook/store.go
- pkg/diunwebhook/sqlite_store.go
- pkg/diunwebhook/postgres_store.go
</read_first>
<behavior>
- 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
</behavior>
<action>
1. 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)
```
2. 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())`
3. 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())`
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./...</automated>
</verify>
<acceptance_criteria>
- 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)
- `go build ./...` exits 0
</acceptance_criteria>
<done>Store interface extended with 2 new methods; both SQLiteStore and PostgresStore compile and implement the interface</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add HTTP handlers, route registration, and tests for bulk acknowledge endpoints</name>
<files>pkg/diunwebhook/diunwebhook.go, pkg/diunwebhook/diunwebhook_test.go, pkg/diunwebhook/export_test.go, cmd/diunwebhook/main.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/diunwebhook_test.go
- pkg/diunwebhook/export_test.go
- cmd/diunwebhook/main.go
</read_first>
<behavior>
- 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
</behavior>
<action>
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 tests in `diunwebhook_test.go` following the existing `Test<Handler>_<Scenario>` 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`.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestAcknowledge(All|ByTag)Handler" ./pkg/diunwebhook/</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Both bulk acknowledge endpoints respond correctly; all new tests pass; route order verified</done>
</task>
</tasks>
<verification>
```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
```
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-ux-improvements/04-01-SUMMARY.md`
</output>