Files
deploy-felhom-compose/TASK.md
T

327 lines
13 KiB
Markdown

# 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: <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) |
### 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 -->
<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.