# TASK: Fix Notification Preferences Sync (Controller → Hub) **Version target:** controller 0.7.2, hub 0.1.5 **Repos:** `deploy-felhom-compose` (controller) + `felhom.eu` (hub) ## Problem When a customer saves notification settings (email + enabled events) on the controller settings page, the preferences are only stored locally in `settings.json`. The hub's `customer_notifications` SQLite table remains empty, so when the controller sends a notification event via `POST /api/v1/notify`, the hub responds with "No email configured for demo-felhom, skipping notification." The email sending infrastructure works (Resend API key is configured, `sendResendEmail` is proven). The gap is that preferences never reach the hub. ## Solution Add a preferences sync endpoint to the hub (`POST /api/v1/preferences`) and have the controller call it when the user saves notification settings. --- ## 1. Hub: Add `POST /api/v1/preferences` Endpoint ### 1.1 Route Add to `hub/internal/api/handler.go` routing: ```go case r.Method == http.MethodPost && path == "/preferences": h.handleSavePreferences(w, r) ``` ### 1.2 Handler: `handleSavePreferences` ```go func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) { // Same bearer token auth as /report and /notify if h.apiKey != "" { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } var payload struct { CustomerID string `json:"customer_id"` Email string `json:"email"` EnabledEvents []string `json:"enabled_events"` } if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" { http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest) return } if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents); err != nil { h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents) w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) } ``` **Note:** `store.SaveNotificationPrefs` already exists and uses `INSERT ... ON CONFLICT DO UPDATE`. No store changes needed. ### 1.3 Edge case: empty email If the customer clears their email and saves, the controller should still sync — this effectively disables notifications for that customer. The hub accepts empty email and stores it (the notify handler already handles `prefs.Email == ""` gracefully). --- ## 2. Controller: Sync Preferences on Save ### 2.1 Add `SyncPreferences` method to `internal/notify/notifier.go` ```go // preferencesRequest is the JSON payload sent to the hub preferences endpoint. type preferencesRequest struct { CustomerID string `json:"customer_id"` Email string `json:"email"` EnabledEvents []string `json:"enabled_events"` } // SyncPreferences pushes the current notification preferences to the hub. // Called after the user saves notification settings on the settings page. // Returns error for the handler to display to the user. func (n *Notifier) SyncPreferences(email string, enabledEvents []string) error { if !n.enabled { return fmt.Errorf("hub nem konfigurált") } payload := preferencesRequest{ CustomerID: n.customerID, Email: email, EnabledEvents: enabledEvents, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal: %w", err) } url := n.hubURL + "/api/v1/preferences" req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData)) if err != nil { return fmt.Errorf("request: %w", err) } req.Header.Set("Authorization", "Bearer "+n.apiKey) req.Header.Set("Content-Type", "application/json") resp, err := n.httpClient.Do(req) if err != nil { return fmt.Errorf("hub elérhetetlen: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("hub hiba (%d): %s", resp.StatusCode, string(body)) } n.logger.Printf("[INFO] Notification preferences synced to hub: email=%s, events=%v", email, enabledEvents) return nil } ``` **Note:** This is a synchronous call (not goroutine) because the handler needs to know if it succeeded to show feedback to the user. ### 2.2 Call `SyncPreferences` in the notification settings POST handler In `internal/web/handlers.go`, the `handleNotificationSettings` handler currently: 1. Parses form data 2. Saves to `settings.json` 3. Redirects with success message Add step 2.5: sync to hub. ```go // After saving to settings.json: if err := s.notifier.SyncPreferences(email, enabledEvents); err != nil { s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err) // Don't block the save — local settings are saved, sync failed // Show warning instead of error // Redirect with: "Értesítési beállítások mentve, de a központi szinkronizálás sikertelen: " } // If sync succeeded: "Értesítési beállítások mentve." ``` **Important:** Local save always succeeds even if hub sync fails. The user sees a warning but their settings are saved. This prevents a hub outage from blocking local config changes. ### 2.3 Flash message variants | Scenario | Message | Color | |---|---|---| | Save + sync OK | "Értesítési beállítások mentve." | Green (success) | | Save OK, sync failed | "Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: [error]" | Yellow (warning) | | Save failed | "Hiba az értesítési beállítások mentésekor: [error]" | Red (error) | ### 2.4 Sync on startup (recommended) When the controller starts and has notification preferences in `settings.json`, sync them to the hub once. This handles the case where the hub was rebuilt (lost its DB) or the controller was moved to a new hub. In `main.go`, after loading settings and creating the notifier: ```go prefs := sett.GetNotificationPrefs() if prefs.Email != "" && notifier.IsEnabled() { if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents); err != nil { logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err) } } ``` Add `IsEnabled() bool` method to Notifier if it doesn't exist (simple getter for `n.enabled`). --- ## 3. Hub: Display Customer Notification Settings (Dashboard) ### 3.1 Customer detail page On the existing hub customer detail page (`/customers/{id}`), add a small "Notifications" section showing: - Email: `nagyfenyvesi.viktor@gmail.com` (or "Not set") - Enabled events: comma-separated list - Recent notification log entries (last 10) ### 3.2 Store: Add `GetRecentNotifications` method ```go type NotificationLogEntry struct { EventType string Severity string Message string Status string // "sent", "skipped", "failed" ErrorMessage string CreatedAt time.Time } func (s *Store) GetRecentNotifications(customerID string, limit int) ([]NotificationLogEntry, error) { rows, err := s.db.Query(` SELECT event_type, severity, message, status, error_message, created_at FROM notification_log WHERE customer_id = ? ORDER BY created_at DESC LIMIT ?`, customerID, limit) // ... scan rows into []NotificationLogEntry } ``` ### 3.3 Customer detail handler update In the web handler that renders the customer detail page, load notification prefs and recent log: ```go notifPrefs, _ := s.store.GetNotificationPrefs(customerID) recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10) // Pass both to template data ``` ### 3.4 Customer detail template addition In `hub/internal/web/templates/customer.html`, add after the Health section: ```html

Notifications

Email {{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}
{{if .NotifPrefs}}
Events {{joinStrings .NotifPrefs.EnabledEvents ", "}}
{{end}}
{{if .RecentNotifications}}

Recent (last 10)

{{range .RecentNotifications}} {{end}}
TimeEventStatusMessage
{{.CreatedAt.Format "Jan 02 15:04"}} {{.EventType}} {{.Status}} {{.Message}}
{{end}}
``` Add `joinStrings` template function if not already available. --- ## 4. Implementation Order ### Step 1: Hub — Add preferences endpoint - Add `POST /api/v1/preferences` route + handler in `handler.go` - No store changes needed (`SaveNotificationPrefs` already exists) - Build hub 0.1.5, deploy to k3s - **Test:** `curl -X POST https://hub.felhom.eu/api/v1/preferences -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"customer_id":"demo-felhom","email":"nagyfenyvesi.viktor@gmail.com","enabled_events":["disk_warning","backup_failed","update_available"]}'` → check hub logs + SQLite ### Step 2: Controller — Sync on settings save - Add `SyncPreferences` method to `notifier.go` - Add `IsEnabled()` method to `notifier.go` if missing - Call from notification settings POST handler in `handlers.go` - Update flash messages for success/warning/error - Build controller 0.7.2, deploy - **Test:** Save notification settings on controller → hub logs "Notification preferences updated" → press "Teszt email küldése" → email arrives in inbox ### Step 3: Controller — Sync on startup - Add startup sync in `main.go` - Rebuild + deploy - **Test:** Restart controller → hub logs show preference sync on startup ### Step 4: Hub — Display notification info on customer page - Add `GetRecentNotifications` to store - Load notification prefs + recent log in customer detail handler - Update `customer.html` template - Add `joinStrings` template function - Rebuild + deploy hub - **Test:** Open hub customer detail page → see email, events, notification log --- ## 5. Files to Create / Modify ### Hub (`felhom.eu`): - `hub/internal/api/handler.go` — Add `handleSavePreferences` handler + route - `hub/internal/store/store.go` — Add `GetRecentNotifications` method + `NotificationLogEntry` struct - `hub/internal/web/server.go` — Load notification prefs + log in customer detail handler, add `joinStrings` template func - `hub/internal/web/templates/customer.html` — Add notification section ### Controller (`deploy-felhom-compose`): - `controller/internal/notify/notifier.go` — Add `SyncPreferences` method, `preferencesRequest` struct, `IsEnabled()` method - `controller/internal/web/handlers.go` — Call `SyncPreferences` after saving notification settings, update flash messages - `controller/cmd/controller/main.go` — Add startup preferences sync --- ## 6. Notes ### Deploy order matters Deploy hub first — the controller needs the `/api/v1/preferences` endpoint to exist. If controller is deployed first, the sync will fail gracefully (warning logged, local save still works). Next save attempt after hub is deployed will succeed. ### Hub DB rebuild recovery The startup sync ensures that if the hub's SQLite is lost (hub redeployment, PVC wipe), the next controller restart will re-populate preferences. This makes the system self-healing. ### Resend API key The hub already has the Resend API key configured — the "DOWN | TEST" email to admin@felhom.eu (visible in Resend dashboard, 11 days ago) proves this. If the key is missing in hub.yaml, the notify handler already logs "Resend API key not configured." That's a config issue, not a code issue. ### Hub dashboard expansion (future, out of scope) The notification section on the customer detail page is read-only. Future hub features like editing notification preferences from the hub side, customer management, and controller.yaml generation are out of scope but this task lays the data model and API groundwork.