From 0fb4dcd7d15cb0d53d16979fb45574307d3daf7a Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Fri, 6 Mar 2026 23:33:12 -0500 Subject: [PATCH] enhanced alert granularity with min/max thresholds --- .../008_add_min_max_to_alert_rules.sql | 6 + backend/infra/schema.sql | 8 +- backend/internal/domain/types.go | 2 + backend/internal/handlers/alert_rule.go | 110 ++++++++- backend/internal/models/alert_rule.go | 46 ++-- backend/internal/services/evaluator.go | 40 +++- backend/internal/wei/converter.go | 8 + frontend/src/api/alerts.jsx | 42 ++++ frontend/src/pages/alerts/Alerts.css | 63 +++++ frontend/src/pages/alerts/Alerts.jsx | 215 +++++++++++++++--- 10 files changed, 479 insertions(+), 61 deletions(-) create mode 100644 backend/infra/migrations/008_add_min_max_to_alert_rules.sql diff --git a/backend/infra/migrations/008_add_min_max_to_alert_rules.sql b/backend/infra/migrations/008_add_min_max_to_alert_rules.sql new file mode 100644 index 0000000..1d46163 --- /dev/null +++ b/backend/infra/migrations/008_add_min_max_to_alert_rules.sql @@ -0,0 +1,6 @@ +ALTER TABLE alert_rules ADD COLUMN minimum DECIMAL(20, 6); +ALTER TABLE alert_rules ADD COLUMN maximum DECIMAL(20, 6); + +ALTER TABLE alert_rules ADD CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0); +ALTER TABLE alert_rules ADD CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0); +ALTER TABLE alert_rules ADD CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum); diff --git a/backend/infra/schema.sql b/backend/infra/schema.sql index ad10f4f..9b02bec 100644 --- a/backend/infra/schema.sql +++ b/backend/infra/schema.sql @@ -35,6 +35,8 @@ CREATE TABLE alert_rules ( address_id INTEGER NOT NULL REFERENCES addresses(id) ON DELETE CASCADE, type VARCHAR(50) NOT NULL, -- 'incoming_tx', 'outgoing_tx', 'large_transfer', 'balance_below' threshold DECIMAL(20, 6), -- ETH amount threshold (nullable for tx types that don't need it) + minimum DECIMAL(20, 6), -- Optional min amount filter for incoming/outgoing alerts + maximum DECIMAL(20, 6), -- Optional max amount filter for incoming/outgoing alerts enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW(), @@ -44,7 +46,11 @@ CREATE TABLE alert_rules ( CONSTRAINT positive_threshold CHECK ( threshold IS NULL OR threshold > 0 - ) + ), + + CONSTRAINT non_negative_minimum CHECK (minimum IS NULL OR minimum >= 0), + CONSTRAINT non_negative_maximum CHECK (maximum IS NULL OR maximum >= 0), + CONSTRAINT min_lte_max CHECK (minimum IS NULL OR maximum IS NULL OR minimum <= maximum) ); diff --git a/backend/internal/domain/types.go b/backend/internal/domain/types.go index 548debb..5a18e87 100644 --- a/backend/internal/domain/types.go +++ b/backend/internal/domain/types.go @@ -73,6 +73,8 @@ type AlertRule struct { AddressID int `json:"address_id"` //nolint:tagliatelle Type AlertType `json:"type"` Threshold *float64 `json:"threshold"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle } diff --git a/backend/internal/handlers/alert_rule.go b/backend/internal/handlers/alert_rule.go index 962127a..0a88b0e 100644 --- a/backend/internal/handlers/alert_rule.go +++ b/backend/internal/handlers/alert_rule.go @@ -37,6 +37,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { var body struct { Type string `json:"type"` Threshold json.RawMessage `json:"threshold"` + Minimum json.RawMessage `json:"minimum"` + Maximum json.RawMessage `json:"maximum"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode alert request body: %v", err) @@ -53,6 +55,40 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { return } + minimum, err := parseThreshold(body.Minimum) + if err != nil { + log.Printf("Failed to parse minimum: %v", err) + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number") + + return + } + + maximum, err := parseThreshold(body.Maximum) + if err != nil { + log.Printf("Failed to parse maximum: %v", err) + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number") + + return + } + + if minimum != nil && *minimum < 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative") + + return + } + + if maximum != nil && *maximum < 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative") + + return + } + + if minimum != nil && maximum != nil && *minimum > *maximum { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum") + + return + } + log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID) if body.Type == "" { @@ -96,7 +132,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { return } - newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold) + newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold, minimum, maximum) if err != nil { log.Printf("Error creating alert rule: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") @@ -182,7 +218,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, alerts) } -// UpdateStatus handles PATCH requests to enable or disable an alert rule. +// UpdateStatus handles PATCH requests to enable/disable an alert rule and/or update min/max thresholds. func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) alertID, ok := parseIntParam(r.PathValue("alertId")) @@ -193,7 +229,10 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) } var body struct { - Enabled *bool `json:"enabled"` + Enabled *bool `json:"enabled"` + Minimum json.RawMessage `json:"minimum"` + Maximum json.RawMessage `json:"maximum"` + UpdateMinMax bool `json:"update_min_max"` //nolint:tagliatelle } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode update request body: %v", err) @@ -204,8 +243,8 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) log.Printf("User %s updating alert ID: %d", userID, alertID) - if body.Enabled == nil { - writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") + if body.Enabled == nil && !body.UpdateMinMax { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "must provide enabled (boolean) or update_min_max with minimum/maximum values") return } @@ -224,15 +263,64 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) return } - updated, err := h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled) - if err != nil { - log.Printf("Error updating alert: %v", err) - writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + var updated *domain.AlertRule - return + if body.UpdateMinMax { + minimum, parseErr := parseThreshold(body.Minimum) + if parseErr != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be a valid number") + + return + } + + maximum, parseErr := parseThreshold(body.Maximum) + if parseErr != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be a valid number") + + return + } + + if minimum != nil && *minimum < 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must be non-negative") + + return + } + + if maximum != nil && *maximum < 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "maximum must be non-negative") + + return + } + + if minimum != nil && maximum != nil && *minimum > *maximum { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "minimum must not exceed maximum") + + return + } + + updated, err = h.alertRules.UpdateThresholds(r.Context(), alertID, minimum, maximum) + if err != nil { + log.Printf("Error updating alert thresholds: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + + return + } + + log.Printf("Alert %d thresholds updated: min=%v, max=%v", alertID, minimum, maximum) + } + + if body.Enabled != nil { + updated, err = h.alertRules.UpdateEnabled(r.Context(), alertID, *body.Enabled) + if err != nil { + log.Printf("Error updating alert: %v", err) + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + + return + } + + log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled) } - log.Printf("Alert %d updated: enabled=%v", alertID, *body.Enabled) writeJSON(w, http.StatusOK, updated) } diff --git a/backend/internal/models/alert_rule.go b/backend/internal/models/alert_rule.go index bbcaf55..4f0a834 100644 --- a/backend/internal/models/alert_rule.go +++ b/backend/internal/models/alert_rule.go @@ -17,14 +17,14 @@ func NewAlertRuleModel(pool *pgxpool.Pool) *AlertRuleModel { return &AlertRuleModel{pool: pool} } -func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold *float64) (*domain.AlertRule, error) { +func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType domain.AlertType, threshold, minimum, maximum *float64) (*domain.AlertRule, error) { var r domain.AlertRule err := m.pool.QueryRow(ctx, - `INSERT INTO alert_rules (address_id, type, threshold, enabled) - VALUES ($1, $2, $3, TRUE) - RETURNING id, address_id, type, threshold, enabled, created_at`, - addressID, alertType.String(), threshold, - ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + `INSERT INTO alert_rules (address_id, type, threshold, minimum, maximum, enabled) + VALUES ($1, $2, $3, $4, $5, TRUE) + RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`, + addressID, alertType.String(), threshold, minimum, maximum, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt) if err != nil { return nil, err } @@ -33,7 +33,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]domain.AlertRule, error) { rows, err := m.pool.Query(ctx, - `SELECT id, address_id, type, threshold, enabled, created_at + `SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at FROM alert_rules WHERE address_id = $1 ORDER BY created_at DESC`, @@ -47,7 +47,7 @@ func (m *AlertRuleModel) ListByAddress(ctx context.Context, addressID int) ([]do var rules []domain.AlertRule for rows.Next() { var r domain.AlertRule - if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt); err != nil { return nil, err } rules = append(rules, r) @@ -61,19 +61,19 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) ( if userID != nil { err = m.pool.QueryRow(ctx, - `SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.enabled, ar.created_at + `SELECT ar.id, ar.address_id, ar.type, ar.threshold, ar.minimum, ar.maximum, ar.enabled, ar.created_at FROM alert_rules ar JOIN addresses a ON a.id = ar.address_id WHERE ar.id = $1 AND a.user_id = $2`, id, *userID, - ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt) } else { err = m.pool.QueryRow(ctx, - `SELECT id, address_id, type, threshold, enabled, created_at + `SELECT id, address_id, type, threshold, minimum, maximum, enabled, created_at FROM alert_rules WHERE id = $1`, id, - ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt) } if err != nil { @@ -91,9 +91,27 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool `UPDATE alert_rules SET enabled = $2 WHERE id = $1 - RETURNING id, address_id, type, threshold, enabled, created_at`, + RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`, id, enabled, - ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt) + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +func (m *AlertRuleModel) UpdateThresholds(ctx context.Context, id int, minimum, maximum *float64) (*domain.AlertRule, error) { + var r domain.AlertRule + err := m.pool.QueryRow(ctx, + `UPDATE alert_rules + SET minimum = $2, maximum = $3 + WHERE id = $1 + RETURNING id, address_id, type, threshold, minimum, maximum, enabled, created_at`, + id, minimum, maximum, + ).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Minimum, &r.Maximum, &r.Enabled, &r.CreatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil diff --git a/backend/internal/services/evaluator.go b/backend/internal/services/evaluator.go index 8bb020b..f3d6740 100644 --- a/backend/internal/services/evaluator.go +++ b/backend/internal/services/evaluator.go @@ -104,9 +104,9 @@ func (s *EvaluatorService) evaluateObservation(ctx context.Context, obs domain.O func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { switch rule.Type { case domain.AlertIncomingTx: - return obs.Direction == domain.DirectionIncoming, nil + return s.matchesDirectionalTx(rule, obs, domain.DirectionIncoming) case domain.AlertOutgoingTx: - return obs.Direction == domain.DirectionOutgoing, nil + return s.matchesDirectionalTx(rule, obs, domain.DirectionOutgoing) case domain.AlertLargeTransfer: return s.matchesLargeTransfer(rule, obs) case domain.AlertBalanceBelow: @@ -117,6 +117,42 @@ func (s *EvaluatorService) ruleMatches(ctx context.Context, rule domain.AlertRul } } +func (s *EvaluatorService) matchesDirectionalTx(rule domain.AlertRule, obs domain.ObservedTx, expected domain.Direction) (bool, error) { + if obs.Direction != expected { + return false, nil + } + + if rule.Minimum != nil { + minWei, err := wei.FromEth(*rule.Minimum) + if err != nil { + return false, err + } + aboveMin, err := wei.GreaterThanOrEqual(obs.Value, minWei) + if err != nil { + return false, err + } + if !aboveMin { + return false, nil + } + } + + if rule.Maximum != nil { + maxWei, err := wei.FromEth(*rule.Maximum) + if err != nil { + return false, err + } + belowMax, err := wei.LessThanOrEqual(obs.Value, maxWei) + if err != nil { + return false, err + } + if !belowMax { + return false, nil + } + } + + return true, nil +} + func (s *EvaluatorService) matchesLargeTransfer(rule domain.AlertRule, obs domain.ObservedTx) (bool, error) { if rule.Threshold == nil { return false, nil diff --git a/backend/internal/wei/converter.go b/backend/internal/wei/converter.go index 2922b7a..840d7f1 100644 --- a/backend/internal/wei/converter.go +++ b/backend/internal/wei/converter.go @@ -90,6 +90,14 @@ func LessThan(weiA, weiB string) (bool, error) { return cmp < 0, nil } +func LessThanOrEqual(weiA, weiB string) (bool, error) { + cmp, err := Compare(weiA, weiB) + if err != nil { + return false, err + } + return cmp <= 0, nil +} + // FormatAsEth formats a Wei string as "X.XXXX ETH". func FormatAsEth(weiString string, decimals int) (string, error) { eth, err := ToEth(weiString) diff --git a/frontend/src/api/alerts.jsx b/frontend/src/api/alerts.jsx index 8f6c383..1c1ca20 100644 --- a/frontend/src/api/alerts.jsx +++ b/frontend/src/api/alerts.jsx @@ -129,6 +129,48 @@ export async function updateAlertStatus(alertId, enabled) { } } +/** + * Update the min/max thresholds on an alert rule + * @param {number} alertId - Alert rule ID + * @param {number|null} minimum - Minimum amount (null to clear) + * @param {number|null} maximum - Maximum amount (null to clear) + * @returns {Promise} Updated alert rule + */ +export async function updateAlertThresholds(alertId, minimum, maximum) { + try { + const headers = await getAuthHeaders(); + const response = await fetch(`${API_BASE}/alerts/${alertId}`, { + method: "PATCH", + headers: headers, + body: JSON.stringify({ + update_min_max: true, + minimum: minimum ?? null, + maximum: maximum ?? null, + }), + }); + + if (!response.ok) { + let errorMessage = "Failed to update alert thresholds"; + try { + const error = await response.json(); + errorMessage = error.message || errorMessage; + } catch { + errorMessage = `Server error: ${response.status} ${response.statusText}`; + } + throw new Error(errorMessage); + } + + return response.json(); + } catch (error) { + if (error.message.includes("fetch")) { + throw new Error( + "Cannot connect to server. Is the backend running?", + ); + } + throw error; + } +} + /** * Delete an alert rule * @param {number} alertId - Alert rule ID diff --git a/frontend/src/pages/alerts/Alerts.css b/frontend/src/pages/alerts/Alerts.css index 72deb7f..c89c7c7 100644 --- a/frontend/src/pages/alerts/Alerts.css +++ b/frontend/src/pages/alerts/Alerts.css @@ -51,6 +51,69 @@ opacity: 0.6; } +.alerts__accordion-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + padding: 0.5rem 0 0; + border-top: 1px solid var(--color-border); + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text-muted); + background: none; + border-left: none; + border-right: none; + border-bottom: none; + width: 100%; + text-align: left; +} + +.alerts__accordion-toggle:hover { + color: var(--color-text); +} + +.alerts__accordion-chevron { + display: inline-block; + transition: transform 0.2s ease; + font-size: 0.7rem; +} + +.alerts__accordion-chevron--open { + transform: rotate(90deg); +} + +.alerts__accordion-panel { + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.25s ease, opacity 0.2s ease, padding 0.25s ease; + padding: 0 0.25rem; +} + +.alerts__accordion-panel--open { + max-height: 200px; + opacity: 1; + padding: 0.75rem 0.25rem 0.25rem; +} + +.alerts__threshold-inputs { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +.alerts__threshold-inputs .form-field { + flex: 1; + margin-bottom: 0; +} + +.alerts__threshold-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + .alerts__email-buttons { display: flex; gap: 0.5rem; diff --git a/frontend/src/pages/alerts/Alerts.jsx b/frontend/src/pages/alerts/Alerts.jsx index 2317f80..34daa8b 100644 --- a/frontend/src/pages/alerts/Alerts.jsx +++ b/frontend/src/pages/alerts/Alerts.jsx @@ -7,6 +7,7 @@ import { getAlerts, createAlert, updateAlertStatus, + updateAlertThresholds, deleteAlert, } from "../../api/alerts"; import { @@ -39,6 +40,64 @@ export default function Alerts() { const [sendingDigest, setSendingDigest] = useState(false); const [hasExistingConfig, setHasExistingConfig] = useState(false); + const [openAccordions, setOpenAccordions] = useState({}); + const [thresholdEdits, setThresholdEdits] = useState({}); + + function toggleAccordion(alertId) { + setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] })); + } + + function getThresholdEdit(alert) { + if (thresholdEdits[alert.id]) return thresholdEdits[alert.id]; + return { + minimum: alert.minimum != null ? String(alert.minimum) : "", + maximum: alert.maximum != null ? String(alert.maximum) : "", + }; + } + + function setThresholdEdit(alertId, field, value) { + setThresholdEdits((prev) => { + const current = prev[alertId] || getThresholdEditForAlert(alertId); + return { ...prev, [alertId]: { ...current, [field]: value } }; + }); + } + + function getThresholdEditForAlert(alertId) { + const alert = alerts.find((a) => a.id === alertId); + return { + minimum: alert?.minimum != null ? String(alert.minimum) : "", + maximum: alert?.maximum != null ? String(alert.maximum) : "", + }; + } + + async function handleSaveThresholds(alertId) { + const edit = thresholdEdits[alertId]; + if (!edit) return; + + const min = edit.minimum.trim() === "" ? null : Number(edit.minimum); + const max = edit.maximum.trim() === "" ? null : Number(edit.maximum); + + if (min !== null && isNaN(min)) return; + if (max !== null && isNaN(max)) return; + if (min !== null && max !== null && min > max) return; + + try { + const updated = await updateAlertThresholds(alertId, min, max); + setAlerts((prev) => + prev.map((a) => (a.id === alertId ? updated : a)), + ); + setThresholdEdits((prev) => { + const next = { ...prev }; + delete next[alertId]; + return next; + }); + setError(null); + } catch (err) { + setError(err.message); + console.error("Failed to update thresholds:", err); + } + } + useEffect(() => { async function fetchData() { try { @@ -314,42 +373,132 @@ export default function Alerts() {

) : ( )}