Files
deploy-felhom-compose/TASK.md
T

13 KiB

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:

case r.Method == http.MethodPost && path == "/preferences":
    h.handleSavePreferences(w, r)

1.2 Handler: handleSavePreferences

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

// 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.

// 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: <err>"
}
// 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)

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:

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

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:

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:

<!-- Notifications -->
<section class="card">
    <h2>Notifications</h2>
    <div class="info-grid">
        <div class="info-item">
            <span class="label">Email</span>
            <span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
        </div>
        {{if .NotifPrefs}}
        <div class="info-item">
            <span class="label">Events</span>
            <span class="value">{{joinStrings .NotifPrefs.EnabledEvents ", "}}</span>
        </div>
        {{end}}
    </div>
    {{if .RecentNotifications}}
    <h3>Recent (last 10)</h3>
    <table class="history-table">
        <thead><tr><th>Time</th><th>Event</th><th>Status</th><th>Message</th></tr></thead>
        <tbody>
            {{range .RecentNotifications}}
            <tr>
                <td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
                <td>{{.EventType}}</td>
                <td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
                <td>{{.Message}}</td>
            </tr>
            {{end}}
        </tbody>
    </table>
    {{end}}
</section>

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 <key>" -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.