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
) : (