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
) : (
- {alerts.map((alert) => (
- -
-
-
-
- {formatAlertType(alert.type)}
-
- {alert.threshold && (
-
- Threshold: {alert.threshold} ETH
+ {alerts.map((alert) => {
+ const hasAccordion =
+ alert.type === "incoming_tx" ||
+ alert.type === "outgoing_tx";
+ const isOpen = !!openAccordions[alert.id];
+ const edit = getThresholdEdit(alert);
+
+ return (
+
-
+
+
+
+ {formatAlertType(alert.type)}
- )}
-
- Status: {alert.enabled ? "Enabled" : "Disabled"}
+ {alert.threshold && (
+
+ Threshold: {alert.threshold} ETH
+
+ )}
+ {alert.minimum != null && (
+
+ Min: {alert.minimum} ETH
+
+ )}
+ {alert.maximum != null && (
+
+ Max: {alert.maximum} ETH
+
+ )}
+
+ Status:{" "}
+ {alert.enabled ? "Enabled" : "Disabled"}
+
+
+
+
+
-
-
-
-
-
-
- ))}
+
+ {hasAccordion && (
+ <>
+
+
+ >
+ )}
+
+ );
+ })}
)}