hub v0.71.0: paired recovery mails (F11), prefs seeding at claim + empty-email no-clobber (F12), priority headers + operator test leg (F14-light)
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
@@ -811,6 +812,62 @@ func (s *Store) GetRecentNotifications(customerID string, limit int) ([]Notifica
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// LastCustomerSentAt returns the most recent notification_log created_at over the given event
|
||||
// types on the CUSTOMER channel with status='sent', and whether any such row exists. It is the
|
||||
// pairing-evidence query for recovery notifications (v0.71.0, audit F11): "was the customer told
|
||||
// about the down since they were last told about a recovery?" Uses the
|
||||
// (customer_id, created_at DESC) index. An empty eventTypes slice returns (zero, false, nil).
|
||||
func (s *Store) LastCustomerSentAt(customerID string, eventTypes []string) (time.Time, bool, error) {
|
||||
if len(eventTypes) == 0 {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
placeholders := make([]string, len(eventTypes))
|
||||
args := make([]interface{}, 0, len(eventTypes)+1)
|
||||
args = append(args, customerID)
|
||||
for i, et := range eventTypes {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, et)
|
||||
}
|
||||
var createdAt sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT MAX(created_at) FROM notification_log
|
||||
WHERE customer_id = ? AND channel = 'customer' AND status = 'sent'
|
||||
AND event_type IN (`+strings.Join(placeholders, ",")+`)`,
|
||||
args...,
|
||||
).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
if !createdAt.Valid || createdAt.String == "" {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return parseSQLiteTime(createdAt.String), true, nil
|
||||
}
|
||||
|
||||
// SeedNotificationPrefs creates a customer_notifications row IF AND ONLY IF none exists —
|
||||
// insert-if-absent, never an upsert (a customer-edited row must never be overwritten by a seed;
|
||||
// audit F12). An empty email is a no-op: seeding an unnotifiable row would only mask the gap.
|
||||
// Returns whether a row was created.
|
||||
func (s *Store) SeedNotificationPrefs(customerID, email string, enabledEvents []string) (bool, error) {
|
||||
if email == "" {
|
||||
return false, nil
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(enabledEvents)
|
||||
res, err := s.db.Exec(`
|
||||
INSERT OR IGNORE INTO customer_notifications (customer_id, email, enabled_events, cooldown_hours)
|
||||
VALUES (?, ?, ?, 6)`,
|
||||
customerID, email, string(eventsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// SaveReport stores a new report. The reportJSON should be the raw JSON payload.
|
||||
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
|
||||
// Parse denormalized fields from the JSON
|
||||
|
||||
Reference in New Issue
Block a user