Hub: add preferences sync endpoint + notification display on customer page
- POST /api/v1/preferences: accepts {customer_id, email, enabled_events} from controller
- GetRecentNotifications() store method for last N notification log entries
- Customer detail page: new Notifications section (email, events, recent log table)
- joinStrings template function for event list display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleReport(w, r)
|
h.handleReport(w, r)
|
||||||
case r.Method == http.MethodPost && path == "/notify":
|
case r.Method == http.MethodPost && path == "/notify":
|
||||||
h.handleNotify(w, r)
|
h.handleNotify(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/preferences":
|
||||||
|
h.handleSavePreferences(w, r)
|
||||||
case r.Method == http.MethodGet && path == "/customers":
|
case r.Method == http.MethodGet && path == "/customers":
|
||||||
h.handleCustomers(w, r)
|
h.handleCustomers(w, r)
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
|
||||||
@@ -282,6 +284,44 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte(`{"status":"ok","sent":true}`))
|
w.Write([]byte(`{"status":"ok","sent":true}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSavePreferences stores notification preferences pushed from a customer controller.
|
||||||
|
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"}`))
|
||||||
|
}
|
||||||
|
|
||||||
// sendResendEmail sends an email via the Resend HTTP API.
|
// sendResendEmail sends an email via the Resend HTTP API.
|
||||||
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
|
|||||||
@@ -151,6 +151,43 @@ func (s *Store) LogNotification(customerID, eventType, severity, message, status
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotificationLogEntry represents a single notification log record.
|
||||||
|
type NotificationLogEntry struct {
|
||||||
|
EventType string
|
||||||
|
Severity string
|
||||||
|
Message string
|
||||||
|
Status string // "sent", "skipped", "failed"
|
||||||
|
ErrorMessage string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentNotifications returns the most recent notification log entries for a customer.
|
||||||
|
func (s *Store) GetRecentNotifications(customerID string, limit int) ([]NotificationLogEntry, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT event_type, severity, message, status, COALESCE(error_message, ''), created_at
|
||||||
|
FROM notification_log
|
||||||
|
WHERE customer_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?`, customerID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []NotificationLogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e NotificationLogEntry
|
||||||
|
var createdAt, errorMsg string
|
||||||
|
if err := rows.Scan(&e.EventType, &e.Severity, &e.Message, &e.Status, &errorMsg, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.CreatedAt = parseSQLiteTime(createdAt)
|
||||||
|
e.ErrorMessage = errorMsg
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// SaveReport stores a new report. The reportJSON should be the raw JSON payload.
|
// SaveReport stores a new report. The reportJSON should be the raw JSON payload.
|
||||||
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
|
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
|
||||||
// Parse denormalized fields from the JSON
|
// Parse denormalized fields from the JSON
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ func New(store *store.Store, passwordHash string, staleThreshold time.Duration,
|
|||||||
"statusColor": statusColor,
|
"statusColor": statusColor,
|
||||||
"statusIcon": statusIcon,
|
"statusIcon": statusIcon,
|
||||||
"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
|
"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
|
||||||
"json": func(v interface{}) template.JS {
|
"joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) },
|
||||||
|
"json": func(v interface{}) template.JS {
|
||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
return template.JS(b)
|
return template.JS(b)
|
||||||
},
|
},
|
||||||
@@ -184,11 +185,17 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
|||||||
// Get history (last 24h)
|
// Get history (last 24h)
|
||||||
history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour)
|
history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour)
|
||||||
|
|
||||||
|
// Get notification preferences and recent log
|
||||||
|
notifPrefs, _ := s.store.GetNotificationPrefs(customerID)
|
||||||
|
recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10)
|
||||||
|
|
||||||
type detailData struct {
|
type detailData struct {
|
||||||
Customer *store.CustomerSummary
|
Customer *store.CustomerSummary
|
||||||
Report map[string]interface{}
|
Report map[string]interface{}
|
||||||
History []store.CustomerSummary
|
History []store.CustomerSummary
|
||||||
OverallStatus string
|
OverallStatus string
|
||||||
|
NotifPrefs *store.NotificationPrefs
|
||||||
|
RecentNotifications []store.NotificationLogEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
overallStatus := "ok"
|
overallStatus := "ok"
|
||||||
@@ -201,10 +208,12 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := detailData{
|
data := detailData{
|
||||||
Customer: customer,
|
Customer: customer,
|
||||||
Report: report,
|
Report: report,
|
||||||
History: history,
|
History: history,
|
||||||
OverallStatus: overallStatus,
|
OverallStatus: overallStatus,
|
||||||
|
NotifPrefs: notifPrefs,
|
||||||
|
RecentNotifications: recentNotifs,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
@@ -155,6 +155,46 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</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">{{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}}</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>
|
||||||
|
|
||||||
<!-- Report History (last 24h) -->
|
<!-- Report History (last 24h) -->
|
||||||
{{if .History}}
|
{{if .History}}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user