docs(02-backend-refactor): create phase plan

This commit is contained in:
2026-03-23 21:46:57 +01:00
parent e72e1d1bea
commit 12cf34ce57
3 changed files with 941 additions and 2 deletions

View File

@@ -43,7 +43,11 @@ Plans:
2. HTTP handlers contain no SQL — all persistence goes through named Store methods
3. Package-level global variables (db, mu, webhookSecret) no longer exist
4. Schema changes are applied via versioned migration files, not ad-hoc DDL in application code
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 02-01-PLAN.md — Create Store interface (9 methods), SQLiteStore implementation, golang-migrate migration infrastructure with embedded SQL files
- [ ] 02-02-PLAN.md — Convert handlers to Server struct methods, remove globals, rewrite tests for per-test isolated databases, update main.go wiring
### Phase 3: PostgreSQL Support
**Goal**: Users running PostgreSQL infrastructure can point DiunDashboard at a Postgres database via DATABASE_URL and the dashboard works identically to the SQLite deployment
@@ -79,6 +83,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Data Integrity | 0/2 | Not started | - |
| 2. Backend Refactor | 0/? | Not started | - |
| 2. Backend Refactor | 0/2 | Not started | - |
| 3. PostgreSQL Support | 0/? | Not started | - |
| 4. UX Improvements | 0/? | Not started | - |

View File

@@ -0,0 +1,362 @@
---
phase: 02-backend-refactor
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pkg/diunwebhook/store.go
- pkg/diunwebhook/sqlite_store.go
- pkg/diunwebhook/migrate.go
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql
- go.mod
- go.sum
autonomous: true
requirements: [REFAC-01, REFAC-03]
must_haves:
truths:
- "A Store interface defines all 9 persistence operations with no SQL or *sql.DB in the contract"
- "SQLiteStore implements every Store method using raw SQL and a sync.Mutex"
- "RunMigrations applies embedded SQL files via golang-migrate and tolerates ErrNoChange"
- "Migration 0001 creates the full current schema including acknowledged_at using CREATE TABLE IF NOT EXISTS"
- "PRAGMA foreign_keys = ON is set in NewSQLiteStore before any queries"
artifacts:
- path: "pkg/diunwebhook/store.go"
provides: "Store interface with 9 methods"
exports: ["Store"]
- path: "pkg/diunwebhook/sqlite_store.go"
provides: "SQLiteStore struct implementing Store"
exports: ["SQLiteStore", "NewSQLiteStore"]
- path: "pkg/diunwebhook/migrate.go"
provides: "RunMigrations function using golang-migrate + embed.FS"
exports: ["RunMigrations"]
- path: "pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql"
provides: "Baseline schema DDL"
contains: "CREATE TABLE IF NOT EXISTS updates"
key_links:
- from: "pkg/diunwebhook/sqlite_store.go"
to: "pkg/diunwebhook/store.go"
via: "interface implementation"
pattern: "func \\(s \\*SQLiteStore\\)"
- from: "pkg/diunwebhook/migrate.go"
to: "pkg/diunwebhook/migrations/sqlite/"
via: "embed.FS"
pattern: "go:embed migrations/sqlite"
---
<objective>
Create the Store interface, SQLiteStore implementation, and golang-migrate migration infrastructure as new files alongside the existing code.
Purpose: Establish the persistence abstraction layer and migration system that Plan 02 will wire into the Server struct and handlers. These are additive-only changes -- nothing existing breaks.
Output: store.go, sqlite_store.go, migrate.go, migration SQL files, golang-migrate dependency installed.
</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/02-backend-refactor/02-RESEARCH.md
<interfaces>
<!-- Current types from diunwebhook.go that Store interface methods must use -->
From pkg/diunwebhook/diunwebhook.go:
```go
type DiunEvent struct {
DiunVersion string `json:"diun_version"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Provider string `json:"provider"`
Image string `json:"image"`
HubLink string `json:"hub_link"`
MimeType string `json:"mime_type"`
Digest string `json:"digest"`
Created time.Time `json:"created"`
Platform string `json:"platform"`
Metadata struct {
ContainerName string `json:"ctn_names"`
ContainerID string `json:"ctn_id"`
State string `json:"ctn_state"`
Status string `json:"ctn_status"`
} `json:"metadata"`
}
type Tag struct {
ID int `json:"id"`
Name string `json:"name"`
}
type UpdateEntry struct {
Event DiunEvent `json:"event"`
ReceivedAt time.Time `json:"received_at"`
Acknowledged bool `json:"acknowledged"`
Tag *Tag `json:"tag"`
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create Store interface and SQLiteStore implementation</name>
<files>pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go (current SQL operations to extract)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (Store interface design, SQL operations inventory)
</read_first>
<action>
**Install golang-migrate dependency first:**
```bash
cd /home/jean-luc-makiola/Development/projects/DiunDashboard
go get github.com/golang-migrate/migrate/v4@v4.19.1
go get github.com/golang-migrate/migrate/v4/database/sqlite
go get github.com/golang-migrate/migrate/v4/source/iofs
```
**Create `pkg/diunwebhook/store.go`** with exactly this interface (per REFAC-01):
```go
package diunwebhook
// Store defines all persistence operations. Implementations must be safe
// for concurrent use from HTTP handlers.
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)
}
```
**Create `pkg/diunwebhook/sqlite_store.go`** with `SQLiteStore` struct implementing all 9 Store methods:
```go
package diunwebhook
import (
"database/sql"
"sync"
"time"
)
type SQLiteStore struct {
db *sql.DB
mu sync.Mutex
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
return &SQLiteStore{db: db}
}
```
Move all SQL from current handlers/functions into Store methods:
1. **UpsertEvent** -- move the INSERT...ON CONFLICT from current `UpdateEvent()` function. Keep exact same SQL including `ON CONFLICT(image) DO UPDATE SET` with all 14 columns and `acknowledged_at = NULL`. Use `time.Now().Format(time.RFC3339)` for received_at. Acquire `s.mu.Lock()`.
2. **GetUpdates** -- move the SELECT...LEFT JOIN from current `GetUpdates()` function. Exact same query: `SELECT u.image, u.diun_version, ...` with LEFT JOIN on tag_assignments and tags. Same row scanning logic with `sql.NullInt64`/`sql.NullString` for tag fields. No mutex needed (read-only).
3. **AcknowledgeUpdate** -- move SQL from `DismissHandler`: `UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`. Return `(found bool, err error)` where found = RowsAffected() > 0. Acquire `s.mu.Lock()`.
4. **ListTags** -- move SQL from `TagsHandler` GET case: `SELECT id, name FROM tags ORDER BY name`. Return `([]Tag, error)`. No mutex.
5. **CreateTag** -- move SQL from `TagsHandler` POST case: `INSERT INTO tags (name) VALUES (?)`. Return `(Tag{ID: int(lastInsertId), Name: name}, error)`. Acquire `s.mu.Lock()`.
6. **DeleteTag** -- move SQL from `TagByIDHandler`: `DELETE FROM tags WHERE id = ?`. Return `(found bool, err error)` where found = RowsAffected() > 0. Acquire `s.mu.Lock()`.
7. **AssignTag** -- move SQL from `TagAssignmentHandler` PUT case: `INSERT OR REPLACE INTO tag_assignments (image, tag_id) VALUES (?, ?)`. Keep `INSERT OR REPLACE` (correct for SQLite, per research Pitfall 6). Acquire `s.mu.Lock()`.
8. **UnassignTag** -- move SQL from `TagAssignmentHandler` DELETE case: `DELETE FROM tag_assignments WHERE image = ?`. Acquire `s.mu.Lock()`.
9. **TagExists** -- move SQL from `TagAssignmentHandler` PUT check: `SELECT COUNT(*) FROM tags WHERE id = ?`. Return `(bool, error)` where bool = count > 0. No mutex (read-only).
**CRITICAL:** `NewSQLiteStore` must run `PRAGMA foreign_keys = ON` on the db connection and `db.SetMaxOpenConns(1)` -- these currently live in `InitDB` and must NOT be lost. Specifically:
```go
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
db.SetMaxOpenConns(1)
// PRAGMA foreign_keys must be set per-connection; with MaxOpenConns(1) this covers all queries
db.Exec("PRAGMA foreign_keys = ON")
return &SQLiteStore{db: db}
}
```
**rows.Close() pattern:** Use `defer rows.Close()` directly (not the verbose closure pattern from the current code). The error from Close() is safe to ignore in read paths.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && echo "BUILD OK"</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/store.go contains `type Store interface {`
- pkg/diunwebhook/store.go contains exactly these 9 method signatures: UpsertEvent, GetUpdates, AcknowledgeUpdate, ListTags, CreateTag, DeleteTag, AssignTag, UnassignTag, TagExists
- pkg/diunwebhook/sqlite_store.go contains `type SQLiteStore struct {`
- pkg/diunwebhook/sqlite_store.go contains `func NewSQLiteStore(db *sql.DB) *SQLiteStore`
- pkg/diunwebhook/sqlite_store.go contains `db.SetMaxOpenConns(1)`
- pkg/diunwebhook/sqlite_store.go contains `PRAGMA foreign_keys = ON`
- pkg/diunwebhook/sqlite_store.go contains `func (s *SQLiteStore) UpsertEvent(event DiunEvent) error`
- pkg/diunwebhook/sqlite_store.go contains `s.mu.Lock()` (mutex usage in write methods)
- pkg/diunwebhook/sqlite_store.go contains `INSERT OR REPLACE INTO tag_assignments` (not ON CONFLICT for this table)
- pkg/diunwebhook/sqlite_store.go contains `ON CONFLICT(image) DO UPDATE SET` (UPSERT for updates table)
- `go build ./pkg/diunwebhook/` exits 0
</acceptance_criteria>
<done>Store interface defines 9 methods; SQLiteStore implements all 9 with exact SQL from current handlers; package compiles with no errors</done>
</task>
<task type="auto">
<name>Task 2: Create migration infrastructure and SQL files</name>
<files>pkg/diunwebhook/migrate.go, pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql, pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go (current DDL in InitDB to extract)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (RunMigrations pattern, migration file design, Pitfall 2 and 4)
</read_first>
<action>
**Create migration SQL files:**
Create directory `pkg/diunwebhook/migrations/sqlite/`.
**`0001_initial_schema.up.sql`** -- Full current schema as a single baseline migration. Use `CREATE TABLE IF NOT EXISTS` for backward compatibility with existing databases (per research recommendation):
```sql
CREATE TABLE IF NOT EXISTS updates (
image TEXT PRIMARY KEY,
diun_version TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
hub_link TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL DEFAULT '',
digest TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
ctn_name TEXT NOT NULL DEFAULT '',
ctn_id TEXT NOT NULL DEFAULT '',
ctn_state TEXT NOT NULL DEFAULT '',
ctn_status TEXT NOT NULL DEFAULT '',
received_at TEXT NOT NULL,
acknowledged_at TEXT
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS tag_assignments (
image TEXT PRIMARY KEY,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);
```
**`0001_initial_schema.down.sql`** -- Reverse of up migration:
```sql
DROP TABLE IF EXISTS tag_assignments;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS updates;
```
**Create `pkg/diunwebhook/migrate.go`:**
```go
package diunwebhook
import (
"database/sql"
"embed"
"errors"
"github.com/golang-migrate/migrate/v4"
sqlitemigrate "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
)
//go:embed migrations/sqlite
var sqliteMigrations embed.FS
// RunMigrations applies all pending schema migrations to the given SQLite database.
// Returns nil if all migrations applied successfully or if database is already up to date.
func RunMigrations(db *sql.DB) error {
src, err := iofs.New(sqliteMigrations, "migrations/sqlite")
if err != nil {
return err
}
driver, err := sqlitemigrate.WithInstance(db, &sqlitemigrate.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
if err != nil {
return err
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
```
**CRITICAL imports:**
- Use `database/sqlite` (NOT `database/sqlite3`) -- the sqlite3 variant requires CGO which is forbidden
- Import alias `sqlitemigrate` for `github.com/golang-migrate/migrate/v4/database/sqlite` to avoid collision with the blank import of `modernc.org/sqlite`
- The `_ "modernc.org/sqlite"` blank import must be present so the "sqlite" driver is registered for `sql.Open`
**After creating files, run:**
```bash
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go mod tidy
```
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && go vet ./pkg/diunwebhook/ && echo "BUILD+VET OK"</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/migrate.go contains `//go:embed migrations/sqlite`
- pkg/diunwebhook/migrate.go contains `func RunMigrations(db *sql.DB) error`
- pkg/diunwebhook/migrate.go contains `!errors.Is(err, migrate.ErrNoChange)` (Pitfall 2 guard)
- pkg/diunwebhook/migrate.go contains `database/sqlite` import (NOT `database/sqlite3`)
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS updates`
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tags`
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tag_assignments`
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `acknowledged_at TEXT` (included in baseline, not a separate migration)
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `ON DELETE CASCADE`
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql contains `DROP TABLE IF EXISTS`
- `go build ./pkg/diunwebhook/` exits 0
- `go vet ./pkg/diunwebhook/` exits 0
- go.mod contains `github.com/golang-migrate/migrate/v4`
</acceptance_criteria>
<done>Migration files exist with full current schema as baseline; RunMigrations function compiles and handles ErrNoChange; golang-migrate v4.19.1 in go.mod; go vet passes</done>
</task>
</tasks>
<verification>
- `go build ./pkg/diunwebhook/` compiles without errors (new files coexist with existing code)
- `go vet ./pkg/diunwebhook/` reports no issues
- `go test ./pkg/diunwebhook/` still passes (existing tests unchanged, new files are additive only)
- go.mod contains golang-migrate v4 dependency
- No CGO: `go mod graph | grep sqlite3` returns empty (no mattn/go-sqlite3 pulled in)
</verification>
<success_criteria>
- Store interface with 9 methods exists in store.go
- SQLiteStore implements all 9 methods in sqlite_store.go with exact SQL semantics from current handlers
- NewSQLiteStore sets PRAGMA foreign_keys = ON and MaxOpenConns(1)
- RunMigrations in migrate.go uses golang-migrate + embed.FS + iofs, handles ErrNoChange
- Migration 0001 contains full current schema with CREATE TABLE IF NOT EXISTS
- All existing tests still pass (no existing code modified)
- No CGO dependency introduced
</success_criteria>
<output>
After completion, create `.planning/phases/02-backend-refactor/02-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,573 @@
---
phase: 02-backend-refactor
plan: 02
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/export_test.go
- pkg/diunwebhook/diunwebhook_test.go
- cmd/diunwebhook/main.go
autonomous: true
requirements: [REFAC-01, REFAC-02, REFAC-03]
must_haves:
truths:
- "All 33 existing tests pass with zero behavior change after the refactor"
- "HTTP handlers contain no SQL -- all persistence goes through Store method calls"
- "Package-level globals db, mu, and webhookSecret no longer exist"
- "main.go constructs SQLiteStore, runs migrations, builds Server, and registers routes"
- "Each test gets its own in-memory database via NewTestServer (no shared global state)"
artifacts:
- path: "pkg/diunwebhook/diunwebhook.go"
provides: "Server struct with handler methods, types, maxBodyBytes constant"
exports: ["Server", "NewServer", "DiunEvent", "UpdateEntry", "Tag"]
- path: "pkg/diunwebhook/export_test.go"
provides: "NewTestServer helper for tests"
exports: ["NewTestServer"]
- path: "cmd/diunwebhook/main.go"
provides: "Wiring: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration"
key_links:
- from: "pkg/diunwebhook/diunwebhook.go"
to: "pkg/diunwebhook/store.go"
via: "Server.store field of type Store"
pattern: "s\\.store\\."
- from: "cmd/diunwebhook/main.go"
to: "pkg/diunwebhook/sqlite_store.go"
via: "diun.NewSQLiteStore(db)"
pattern: "NewSQLiteStore"
- from: "cmd/diunwebhook/main.go"
to: "pkg/diunwebhook/migrate.go"
via: "diun.RunMigrations(db)"
pattern: "RunMigrations"
- from: "pkg/diunwebhook/diunwebhook_test.go"
to: "pkg/diunwebhook/export_test.go"
via: "diun.NewTestServer()"
pattern: "NewTestServer"
---
<objective>
Convert all handlers from package-level functions to Server struct methods, remove global state, rewrite tests to use per-test in-memory databases, and update main.go to wire everything together.
Purpose: Complete the refactor so handlers use the Store interface (no SQL in handlers), globals are eliminated, and each test is isolated with its own database. This is the "big flip" that makes the codebase ready for PostgreSQL support.
Output: Refactored diunwebhook.go, rewritten export_test.go + test file, updated main.go. All existing tests pass.
</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/02-backend-refactor/02-RESEARCH.md
@.planning/phases/02-backend-refactor/02-01-SUMMARY.md
<interfaces>
<!-- From Plan 01 outputs -- these files will exist when this plan runs -->
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:
```go
type SQLiteStore struct { db *sql.DB; mu sync.Mutex }
func NewSQLiteStore(db *sql.DB) *SQLiteStore
```
From pkg/diunwebhook/migrate.go:
```go
func RunMigrations(db *sql.DB) error
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Convert diunwebhook.go to Server struct and update main.go</name>
<files>pkg/diunwebhook/diunwebhook.go, cmd/diunwebhook/main.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go (full current file -- handlers to convert)
- pkg/diunwebhook/store.go (Store interface from Plan 01)
- pkg/diunwebhook/sqlite_store.go (SQLiteStore from Plan 01)
- pkg/diunwebhook/migrate.go (RunMigrations from Plan 01)
- cmd/diunwebhook/main.go (current wiring to replace)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (Server struct pattern, handler method pattern)
</read_first>
<action>
**Refactor `pkg/diunwebhook/diunwebhook.go`:**
1. **Remove all package-level globals** -- delete these 3 lines entirely:
```go
var (
mu sync.Mutex
db *sql.DB
webhookSecret string
)
```
2. **Remove `SetWebhookSecret` function** -- delete entirely (replaced by NewServer constructor).
3. **Remove `InitDB` function** -- delete entirely (replaced by RunMigrations + NewSQLiteStore in main.go).
4. **Remove `UpdateEvent` function** -- delete entirely (moved to SQLiteStore.UpsertEvent in sqlite_store.go).
5. **Remove `GetUpdates` function** -- delete entirely (moved to SQLiteStore.GetUpdates in sqlite_store.go).
6. **Add Server struct and constructor:**
```go
type Server struct {
store Store
webhookSecret string
}
func NewServer(store Store, webhookSecret string) *Server {
return &Server{store: store, webhookSecret: webhookSecret}
}
```
7. **Convert all 6 handler functions to methods on `*Server`:**
- `func WebhookHandler(w, r)` becomes `func (s *Server) WebhookHandler(w, r)`
- `func UpdatesHandler(w, r)` becomes `func (s *Server) UpdatesHandler(w, r)`
- `func DismissHandler(w, r)` becomes `func (s *Server) DismissHandler(w, r)`
- `func TagsHandler(w, r)` becomes `func (s *Server) TagsHandler(w, r)`
- `func TagByIDHandler(w, r)` becomes `func (s *Server) TagByIDHandler(w, r)`
- `func TagAssignmentHandler(w, r)` becomes `func (s *Server) TagAssignmentHandler(w, r)`
8. **Replace all inline SQL in handlers with Store method calls:**
In `WebhookHandler`: replace `UpdateEvent(event)` with `s.store.UpsertEvent(event)`. Keep all auth checks, method checks, MaxBytesReader, and JSON decode logic. Keep exact same error messages and status codes.
In `UpdatesHandler`: replace `GetUpdates()` with `s.store.GetUpdates()`. Keep JSON encoding logic.
In `DismissHandler`: replace the `mu.Lock(); db.Exec(UPDATE...); mu.Unlock()` block with:
```go
found, err := s.store.AcknowledgeUpdate(image)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.Error(w, "not found", http.StatusNotFound)
return
}
```
In `TagsHandler` GET case: replace `db.Query(SELECT...)` block with:
```go
tags, err := s.store.ListTags()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tags)
```
In `TagsHandler` POST case: replace `mu.Lock(); db.Exec(INSERT...)` block with:
```go
tag, err := s.store.CreateTag(req.Name)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
http.Error(w, "conflict: tag name already exists", http.StatusConflict)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tag)
```
In `TagByIDHandler`: replace `mu.Lock(); db.Exec(DELETE...)` block with:
```go
found, err := s.store.DeleteTag(id)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.Error(w, "not found", http.StatusNotFound)
return
}
```
In `TagAssignmentHandler` PUT case: replace tag-exists check + INSERT with:
```go
exists, err := s.store.TagExists(req.TagID)
if err != nil || !exists {
http.Error(w, "not found: tag does not exist", http.StatusNotFound)
return
}
if err := s.store.AssignTag(req.Image, req.TagID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
```
In `TagAssignmentHandler` DELETE case: replace `mu.Lock(); db.Exec(DELETE...)` with:
```go
if err := s.store.UnassignTag(req.Image); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
```
9. **Keep in diunwebhook.go:** The 3 type definitions (`DiunEvent`, `Tag`, `UpdateEntry`), the `maxBodyBytes` constant. Remove imports that are no longer needed (`database/sql`, `sync`, `time` if unused). Add `time` back only if still needed. The `crypto/subtle` import stays for webhook auth.
10. **Update `diunwebhook.go` imports** -- remove: `database/sql`, `sync`, `time` (if no longer used after removing UpdateEvent/GetUpdates). Keep: `crypto/subtle`, `encoding/json`, `errors`, `log`, `net/http`, `strconv`, `strings`. Remove the blank import `_ "modernc.org/sqlite"` (it moves to migrate.go or sqlite_store.go).
**Update `cmd/diunwebhook/main.go`:**
Replace the current `InitDB` + `SetWebhookSecret` + package-level handler registration with:
```go
package main
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
diun "awesomeProject/pkg/diunwebhook"
_ "modernc.org/sqlite"
)
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./diun.db"
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("sql.Open: %v", err)
}
if err := diun.RunMigrations(db); err != nil {
log.Fatalf("RunMigrations: %v", err)
}
store := diun.NewSQLiteStore(db)
secret := os.Getenv("WEBHOOK_SECRET")
if secret == "" {
log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
} else {
log.Println("Webhook endpoint protected with token authentication")
}
srv := diun.NewServer(store, secret)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/webhook", srv.WebhookHandler)
mux.HandleFunc("/api/updates/", srv.DismissHandler)
mux.HandleFunc("/api/updates", srv.UpdatesHandler)
mux.HandleFunc("/api/tags", srv.TagsHandler)
mux.HandleFunc("/api/tags/", srv.TagByIDHandler)
mux.HandleFunc("/api/tag-assignments", srv.TagAssignmentHandler)
mux.Handle("/", http.FileServer(http.Dir("./frontend/dist")))
httpSrv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("Listening on :%s", port)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("ListenAndServe: %v", err)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
} else {
log.Println("Server stopped cleanly")
}
}
```
Key changes in main.go:
- `sql.Open` called directly (not via InitDB)
- `diun.RunMigrations(db)` called before store creation
- `diun.NewSQLiteStore(db)` creates the store (sets PRAGMA, MaxOpenConns internally)
- `diun.NewServer(store, secret)` creates the server
- Route registration uses `srv.WebhookHandler` (method) instead of `diun.WebhookHandler` (package function)
- `_ "modernc.org/sqlite"` blank import is in main.go (driver registration)
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./cmd/diunwebhook/ && go build ./pkg/diunwebhook/ && go vet ./... && echo "BUILD+VET OK"</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/diunwebhook.go contains `type Server struct {`
- pkg/diunwebhook/diunwebhook.go contains `func NewServer(store Store, webhookSecret string) *Server`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) WebhookHandler(`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) UpdatesHandler(`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) DismissHandler(`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) TagsHandler(`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) TagByIDHandler(`
- pkg/diunwebhook/diunwebhook.go contains `func (s *Server) TagAssignmentHandler(`
- pkg/diunwebhook/diunwebhook.go contains `s.store.UpsertEvent` (handler calls store, not direct SQL)
- pkg/diunwebhook/diunwebhook.go does NOT contain `var db *sql.DB` (global removed)
- pkg/diunwebhook/diunwebhook.go does NOT contain `var mu sync.Mutex` (global removed)
- pkg/diunwebhook/diunwebhook.go does NOT contain `var webhookSecret string` (global removed)
- pkg/diunwebhook/diunwebhook.go does NOT contain `func InitDB(` (removed)
- pkg/diunwebhook/diunwebhook.go does NOT contain `func SetWebhookSecret(` (removed)
- pkg/diunwebhook/diunwebhook.go does NOT contain `db.Exec(` or `db.Query(` (no SQL in handlers)
- cmd/diunwebhook/main.go contains `diun.RunMigrations(db)`
- cmd/diunwebhook/main.go contains `diun.NewSQLiteStore(db)`
- cmd/diunwebhook/main.go contains `diun.NewServer(store, secret)`
- cmd/diunwebhook/main.go contains `srv.WebhookHandler` (method reference, not package function)
- `go build ./cmd/diunwebhook/` exits 0
- `go vet ./...` exits 0
</acceptance_criteria>
<done>Handlers are methods on Server calling s.store.X(); no package-level globals remain; main.go wires sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> routes; both packages compile and pass go vet</done>
</task>
<task type="auto">
<name>Task 2: Rewrite export_test.go and update all tests for Server/Store</name>
<files>pkg/diunwebhook/export_test.go, pkg/diunwebhook/diunwebhook_test.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook_test.go (all 33 existing tests to convert)
- pkg/diunwebhook/export_test.go (current helpers to replace)
- pkg/diunwebhook/diunwebhook.go (refactored Server/handler signatures from Task 1)
- pkg/diunwebhook/store.go (Store interface)
- pkg/diunwebhook/sqlite_store.go (NewSQLiteStore)
- pkg/diunwebhook/migrate.go (RunMigrations)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (export_test.go redesign pattern)
</read_first>
<action>
**Rewrite `pkg/diunwebhook/export_test.go`:**
Replace the entire file. The old helpers (`UpdatesReset`, `GetUpdatesMap`, `ResetTags`, `ResetWebhookSecret`) relied on package-level globals that no longer exist.
New content:
```go
package diunwebhook
import "database/sql"
// NewTestServer constructs a Server with a fresh in-memory SQLite database.
// Each call returns an isolated server -- tests do not share state.
func NewTestServer() (*Server, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
}
if err := RunMigrations(db); err != nil {
return nil, err
}
store := NewSQLiteStore(db)
return NewServer(store, ""), nil
}
// NewTestServerWithSecret constructs a Server with webhook authentication enabled.
func NewTestServerWithSecret(secret string) (*Server, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
}
if err := RunMigrations(db); err != nil {
return nil, err
}
store := NewSQLiteStore(db)
return NewServer(store, secret), nil
}
```
**Rewrite `pkg/diunwebhook/diunwebhook_test.go`:**
The test file is `package diunwebhook_test` (external test package). Every test that previously called `diun.UpdatesReset()` to get a clean global DB must now call `diun.NewTestServer()` to get its own isolated server.
**Conversion pattern for every test:**
OLD:
```go
func TestFoo(t *testing.T) {
diun.UpdatesReset()
// ... uses diun.WebhookHandler, diun.UpdateEvent, diun.GetUpdatesMap, etc.
}
```
NEW:
```go
func TestFoo(t *testing.T) {
srv, err := diun.NewTestServer()
if err != nil {
t.Fatalf("NewTestServer: %v", err)
}
// ... uses srv.WebhookHandler, srv.Store().UpsertEvent, etc.
}
```
**But wait:** `srv.Store()` does not exist -- the `store` field is unexported. Tests need a way to call `UpsertEvent` and `GetUpdates` directly. Two options:
Option A: Add a `Store()` accessor method to Server (exported, for tests).
Option B: Add test-helper functions in export_test.go that access `s.store` directly (since export_test.go is in the internal package).
**Use Option B** -- add these helpers in export_test.go:
```go
// TestUpsertEvent calls UpsertEvent on the server's store (for test setup).
func (s *Server) TestUpsertEvent(event DiunEvent) error {
return s.store.UpsertEvent(event)
}
// TestGetUpdates calls GetUpdates on the server's store (for test assertions).
func (s *Server) TestGetUpdates() (map[string]UpdateEntry, error) {
return s.store.GetUpdates()
}
// TestGetUpdatesMap is a convenience wrapper that returns the map without error.
func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry {
m, _ := s.store.GetUpdates()
return m
}
```
**Now convert each test function. Here are the specific conversions for ALL tests:**
1. **Remove `TestMain`** -- it only called `diun.UpdatesReset()` which is no longer needed since each test creates its own server.
2. **`TestUpdateEventAndGetUpdates`** -- replace `diun.UpdatesReset()` with `srv, err := diun.NewTestServer()`. Replace `diun.UpdateEvent(event)` with `srv.TestUpsertEvent(event)`. Replace `diun.GetUpdates()` with `srv.TestGetUpdates()`.
3. **`TestWebhookHandler`** -- replace `diun.UpdatesReset()` with `srv, err := diun.NewTestServer()`. Replace `diun.WebhookHandler(rec, req)` with `srv.WebhookHandler(rec, req)`. Replace `diun.GetUpdatesMap()` with `srv.TestGetUpdatesMap()`.
4. **`TestWebhookHandler_Unauthorized`** -- replace with `srv, err := diun.NewTestServerWithSecret("my-secret")`. Remove `defer diun.ResetWebhookSecret()`. Replace `diun.WebhookHandler` with `srv.WebhookHandler`.
5. **`TestWebhookHandler_WrongToken`** -- same as Unauthorized: use `NewTestServerWithSecret("my-secret")`.
6. **`TestWebhookHandler_ValidToken`** -- use `NewTestServerWithSecret("my-secret")`.
7. **`TestWebhookHandler_NoSecretConfigured`** -- use `diun.NewTestServer()` (no secret = open webhook).
8. **`TestWebhookHandler_BadRequest`** -- use `diun.NewTestServer()`. (Note: the old test did NOT call `UpdatesReset`, but it should use a server now.) Replace `diun.WebhookHandler` with `srv.WebhookHandler`.
9. **`TestUpdatesHandler`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent(event)` with `srv.TestUpsertEvent(event)`. Replace `diun.UpdatesHandler` with `srv.UpdatesHandler`.
10. **`TestUpdatesHandler_EncodeError`** -- use `diun.NewTestServer()`. Replace `diun.UpdatesHandler` with `srv.UpdatesHandler`.
11. **`TestWebhookHandler_MethodNotAllowed`** -- use `diun.NewTestServer()`. Replace all `diun.WebhookHandler` with `srv.WebhookHandler`.
12. **`TestWebhookHandler_EmptyImage`** -- use `diun.NewTestServer()`. Replace handler + `GetUpdatesMap` calls.
13. **`TestConcurrentUpdateEvent`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent(...)` with `srv.TestUpsertEvent(...)`. Replace `diun.GetUpdatesMap()` with `srv.TestGetUpdatesMap()`. **Note:** t.Fatalf cannot be called from goroutines. This is a pre-existing issue in the test. Change to `t.Errorf` inside goroutines (or use a channel/error collection pattern). The current code already has this bug -- preserve the existing behavior for now but change `t.Fatalf` to `t.Errorf` inside the goroutine.
14. **`TestMainHandlerIntegration`** -- use `diun.NewTestServer()`. Replace the inline handler router to use `srv.WebhookHandler` and `srv.UpdatesHandler` in the httptest.NewServer setup.
15. **`TestDismissHandler_Success`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent` -> `srv.TestUpsertEvent`. Replace `diun.DismissHandler` -> `srv.DismissHandler`. Replace `diun.GetUpdatesMap` -> `srv.TestGetUpdatesMap`.
16. **`TestDismissHandler_NotFound`** -- use `diun.NewTestServer()`. Replace handler call.
17. **`TestDismissHandler_EmptyImage`** -- use `diun.NewTestServer()`. Replace handler call.
18. **`TestDismissHandler_SlashInImageName`** -- use `diun.NewTestServer()`. Replace all calls.
19. **`TestDismissHandler_ReappearsAfterNewWebhook`** -- use `diun.NewTestServer()`. Replace all calls. The `diun.UpdateEvent(...)` call without error check becomes `srv.TestUpsertEvent(...)` -- add an error check.
20. **Helper functions `postTag` and `postTagAndGetID`** -- these need the server as a parameter. Change signatures:
```go
func postTag(t *testing.T, srv *diun.Server, name string) (int, int)
func postTagAndGetID(t *testing.T, srv *diun.Server, name string) int
```
Replace `diun.TagsHandler(rec, req)` with `srv.TagsHandler(rec, req)`.
21. **All tag tests** (`TestCreateTagHandler_Success`, `TestCreateTagHandler_DuplicateName`, `TestCreateTagHandler_EmptyName`, `TestGetTagsHandler_Empty`, `TestGetTagsHandler_WithTags`, `TestDeleteTagHandler_Success`, `TestDeleteTagHandler_NotFound`, `TestDeleteTagHandler_CascadesAssignment`) -- use `diun.NewTestServer()`. Replace all handler calls. Pass `srv` to helper functions.
22. **All tag assignment tests** (`TestTagAssignmentHandler_Assign`, `TestTagAssignmentHandler_Reassign`, `TestTagAssignmentHandler_Unassign`, `TestGetUpdates_IncludesTag`) -- use `diun.NewTestServer()`. Replace all calls.
23. **Oversized body tests** (`TestWebhookHandler_OversizedBody`, `TestTagsHandler_OversizedBody`, `TestTagAssignmentHandler_OversizedBody`) -- use `diun.NewTestServer()`. Replace handler calls.
24. **`TestUpdateEvent_PreservesTagOnUpsert`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent` -> `srv.TestUpsertEvent`. Replace handler calls. Replace `diun.GetUpdatesMap` -> `srv.TestGetUpdatesMap`.
**Remove these imports from test file** (no longer needed):
- `os` (was for TestMain's os.Exit)
**Verify all HTTP status codes, error messages, and assertion logic remain IDENTICAL to the original tests.** The only change is the source of the handler function (method on srv instead of package function) and the source of test data (srv.TestUpsertEvent instead of diun.UpdateEvent).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -40</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/export_test.go contains `func NewTestServer() (*Server, error)`
- pkg/diunwebhook/export_test.go contains `func NewTestServerWithSecret(secret string) (*Server, error)`
- pkg/diunwebhook/export_test.go contains `func (s *Server) TestUpsertEvent(event DiunEvent) error`
- pkg/diunwebhook/export_test.go contains `func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry`
- pkg/diunwebhook/export_test.go does NOT contain `func UpdatesReset()` (old helper removed)
- pkg/diunwebhook/export_test.go does NOT contain `func ResetWebhookSecret()` (old helper removed)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain `diun.UpdatesReset()` (replaced with NewTestServer)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain `diun.SetWebhookSecret(` (replaced with NewTestServerWithSecret)
- pkg/diunwebhook/diunwebhook_test.go contains `diun.NewTestServer()` (new pattern)
- pkg/diunwebhook/diunwebhook_test.go contains `srv.WebhookHandler(` (method call, not package function)
- pkg/diunwebhook/diunwebhook_test.go contains `srv.TestUpsertEvent(` (test helper)
- pkg/diunwebhook/diunwebhook_test.go contains `srv.TestGetUpdatesMap()` (test helper)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain `func TestMain(` (removed, no longer needed)
- `go test -v -count=1 ./pkg/diunwebhook/` exits 0 with all tests passing
- `go test -v -count=1 ./pkg/diunwebhook/` output contains `PASS`
</acceptance_criteria>
<done>All existing tests pass against the new Server/Store architecture; each test has its own in-memory database; no shared global state; test output shows PASS with 0 failures</done>
</task>
</tasks>
<verification>
- `go test -v -count=1 ./pkg/diunwebhook/` -- ALL tests pass (same test count as before the refactor)
- `go build ./cmd/diunwebhook/` -- binary compiles
- `go vet ./...` -- no issues
- `grep -r 'var db \|var mu \|var webhookSecret' pkg/diunwebhook/diunwebhook.go` -- returns empty (globals removed)
- `grep -r 'db\.Exec\|db\.Query\|db\.QueryRow' pkg/diunwebhook/diunwebhook.go` -- returns empty (no SQL in handlers)
- `grep 's\.store\.' pkg/diunwebhook/diunwebhook.go` -- returns multiple matches (handlers use Store interface)
- `grep 'diun\.UpdatesReset' pkg/diunwebhook/diunwebhook_test.go` -- returns empty (old pattern gone)
</verification>
<success_criteria>
- All existing tests pass with zero behavior change (same HTTP status codes, same error messages, same data semantics)
- HTTP handlers contain no SQL -- every persistence call goes through s.store.X()
- Package-level globals db, mu, webhookSecret are deleted from diunwebhook.go
- main.go wires: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration
- Each test creates its own in-memory database via NewTestServer() (parallel-safe)
- go vet passes on all packages
</success_criteria>
<output>
After completion, create `.planning/phases/02-backend-refactor/02-02-SUMMARY.md`
</output>