summaryrefslogtreecommitdiff
path: root/internal/storage
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 20:43:28 +0000
commit7f920ca63af5329c19a0e5a879c649c594beea35 (patch)
tree803150e7c895d3232bad35c729aad647aaa54348 /internal/storage
parent072652f617653dce74368cedb42b88189e5014fb (diff)
feat: add web push notifications and file drop
Web Push: - WebPushNotifier with VAPID auth; urgency mapped to event type (BLOCKED=urgent, FAILED=high, COMPLETED=low) - Auto-generates VAPID keys on first serve, persists to config file - push_subscriptions table in SQLite (upsert by endpoint) - GET /api/push/vapid-key, POST/DELETE /api/push/subscribe endpoints - Service worker (sw.js) handles push events and notification clicks - Notification bell button in web UI; subscribes on click File Drop: - GET /api/drops, GET /api/drops/{filename}, POST /api/drops - Persistent ~/.claudomator/drops/ directory - CLAUDOMATOR_DROP_DIR env var passed to agent subprocesses - Drops tab (📁) in web UI with file listing and download links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/storage')
-rw-r--r--internal/storage/db.go66
-rw-r--r--internal/storage/db_test.go92
2 files changed, 158 insertions, 0 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go
index f07ddfe..51121e1 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -87,6 +87,13 @@ func (s *DB) migrate() error {
`ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`,
`ALTER TABLE tasks ADD COLUMN elaboration_input TEXT`,
`ALTER TABLE tasks ADD COLUMN project TEXT`,
+ `CREATE TABLE IF NOT EXISTS push_subscriptions (
+ id TEXT PRIMARY KEY,
+ endpoint TEXT NOT NULL UNIQUE,
+ p256dh_key TEXT NOT NULL,
+ auth_key TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
@@ -769,3 +776,62 @@ func (s *DB) UpdateExecutionChangestats(execID string, stats *task.Changestats)
func scanExecutionRows(rows *sql.Rows) (*Execution, error) {
return scanExecution(rows)
}
+
+// PushSubscription represents a browser push subscription.
+type PushSubscription struct {
+ ID string `json:"id"`
+ Endpoint string `json:"endpoint"`
+ P256DHKey string `json:"p256dh_key"`
+ AuthKey string `json:"auth_key"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// SavePushSubscription inserts or replaces a push subscription by endpoint.
+func (s *DB) SavePushSubscription(sub PushSubscription) error {
+ _, err := s.db.Exec(`
+ INSERT INTO push_subscriptions (id, endpoint, p256dh_key, auth_key)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(endpoint) DO UPDATE SET
+ id = excluded.id,
+ p256dh_key = excluded.p256dh_key,
+ auth_key = excluded.auth_key`,
+ sub.ID, sub.Endpoint, sub.P256DHKey, sub.AuthKey,
+ )
+ return err
+}
+
+// DeletePushSubscription removes the subscription with the given endpoint.
+func (s *DB) DeletePushSubscription(endpoint string) error {
+ _, err := s.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
+ return err
+}
+
+// ListPushSubscriptions returns all registered push subscriptions.
+func (s *DB) ListPushSubscriptions() ([]PushSubscription, error) {
+ rows, err := s.db.Query(`SELECT id, endpoint, p256dh_key, auth_key, created_at FROM push_subscriptions ORDER BY created_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []PushSubscription
+ for rows.Next() {
+ var sub PushSubscription
+ var createdAt string
+ if err := rows.Scan(&sub.ID, &sub.Endpoint, &sub.P256DHKey, &sub.AuthKey, &createdAt); err != nil {
+ return nil, err
+ }
+ // Parse created_at; ignore errors (use zero time on failure).
+ for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
+ if t, err := time.Parse(layout, createdAt); err == nil {
+ sub.CreatedAt = t
+ break
+ }
+ }
+ subs = append(subs, sub)
+ }
+ if subs == nil {
+ subs = []PushSubscription{}
+ }
+ return subs, rows.Err()
+}
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index 9b89e89..5c447af 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -1020,6 +1020,98 @@ func TestCreateTask_Project_RoundTrip(t *testing.T) {
}
}
+// ── Push subscription tests ───────────────────────────────────────────────────
+
+func TestPushSubscription_SaveAndList(t *testing.T) {
+ db := testDB(t)
+
+ sub := PushSubscription{
+ ID: "sub-1",
+ Endpoint: "https://push.example.com/endpoint1",
+ P256DHKey: "p256dhkey1",
+ AuthKey: "authkey1",
+ }
+ if err := db.SavePushSubscription(sub); err != nil {
+ t.Fatalf("SavePushSubscription: %v", err)
+ }
+
+ subs, err := db.ListPushSubscriptions()
+ if err != nil {
+ t.Fatalf("ListPushSubscriptions: %v", err)
+ }
+ if len(subs) != 1 {
+ t.Fatalf("want 1 subscription, got %d", len(subs))
+ }
+ if subs[0].Endpoint != sub.Endpoint {
+ t.Errorf("endpoint: want %q, got %q", sub.Endpoint, subs[0].Endpoint)
+ }
+ if subs[0].P256DHKey != sub.P256DHKey {
+ t.Errorf("p256dh_key: want %q, got %q", sub.P256DHKey, subs[0].P256DHKey)
+ }
+ if subs[0].AuthKey != sub.AuthKey {
+ t.Errorf("auth_key: want %q, got %q", sub.AuthKey, subs[0].AuthKey)
+ }
+}
+
+func TestPushSubscription_Delete(t *testing.T) {
+ db := testDB(t)
+
+ sub := PushSubscription{
+ ID: "sub-del",
+ Endpoint: "https://push.example.com/todelete",
+ P256DHKey: "key",
+ AuthKey: "auth",
+ }
+ if err := db.SavePushSubscription(sub); err != nil {
+ t.Fatalf("SavePushSubscription: %v", err)
+ }
+
+ if err := db.DeletePushSubscription(sub.Endpoint); err != nil {
+ t.Fatalf("DeletePushSubscription: %v", err)
+ }
+
+ subs, err := db.ListPushSubscriptions()
+ if err != nil {
+ t.Fatalf("ListPushSubscriptions: %v", err)
+ }
+ if len(subs) != 0 {
+ t.Errorf("want 0 subscriptions after delete, got %d", len(subs))
+ }
+}
+
+func TestPushSubscription_UniqueEndpoint(t *testing.T) {
+ db := testDB(t)
+
+ sub := PushSubscription{
+ ID: "sub-uq",
+ Endpoint: "https://push.example.com/unique",
+ P256DHKey: "key1",
+ AuthKey: "auth1",
+ }
+ if err := db.SavePushSubscription(sub); err != nil {
+ t.Fatalf("SavePushSubscription first: %v", err)
+ }
+
+ // Save again with same endpoint — should update or replace, not error.
+ sub2 := PushSubscription{
+ ID: "sub-uq2",
+ Endpoint: "https://push.example.com/unique",
+ P256DHKey: "key2",
+ AuthKey: "auth2",
+ }
+ if err := db.SavePushSubscription(sub2); err != nil {
+ t.Fatalf("SavePushSubscription second (upsert): %v", err)
+ }
+
+ subs, err := db.ListPushSubscriptions()
+ if err != nil {
+ t.Fatalf("ListPushSubscriptions: %v", err)
+ }
+ if len(subs) != 1 {
+ t.Errorf("want 1 subscription after upsert, got %d", len(subs))
+ }
+}
+
func TestExecution_StoreAndRetrieveChangestats(t *testing.T) {
db := testDB(t)
now := time.Now().UTC().Truncate(time.Second)