enhanced alert granularity with min/max thresholds
This commit is contained in:
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user