@@ -1,4 +1,4 @@
|
||||
// Package config loads environment-based configuration.
|
||||
//loads environment-based configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -9,17 +9,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultPort is the default HTTP server port.
|
||||
defaultPort = 3001
|
||||
// defaultDBPort is the default PostgreSQL port.
|
||||
defaultDBPort = 5432
|
||||
// defaultPollIntervalMS is the default poller interval in milliseconds.
|
||||
defaultPollIntervalMS = 60000
|
||||
// minPollIntervalMS is the minimum allowed poller interval.
|
||||
minPollIntervalMS = 1000
|
||||
)
|
||||
|
||||
// Config holds application configuration.
|
||||
type Config struct {
|
||||
Port int
|
||||
APIBasePath string
|
||||
@@ -59,10 +54,8 @@ func Load() (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DSN returns the PostgreSQL data source name for the configured database.
|
||||
func (c *Config) DSN() string {
|
||||
if c.DatabaseURL != "" {
|
||||
// pgx defaults to sslmode=prefer, which fails against local Postgres.
|
||||
// Append sslmode=disable if not already specified.
|
||||
if !strings.Contains(c.DatabaseURL, "sslmode=") {
|
||||
sep := "?"
|
||||
|
||||
@@ -15,6 +15,9 @@ type Address struct {
|
||||
// AlertType identifies the kind of alert rule.
|
||||
type AlertType string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (a AlertType) String() string { return string(a) }
|
||||
|
||||
// Alert type constants define the supported alert triggers.
|
||||
const (
|
||||
AlertIncomingTx AlertType = "incoming_tx"
|
||||
@@ -24,7 +27,7 @@ const (
|
||||
)
|
||||
|
||||
// ValidAlertTypes lists all alert types accepted by the API.
|
||||
var ValidAlertTypes = []AlertType{
|
||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertIncomingTx,
|
||||
AlertOutgoingTx,
|
||||
AlertLargeTransfer,
|
||||
@@ -32,7 +35,7 @@ var ValidAlertTypes = []AlertType{
|
||||
}
|
||||
|
||||
// ThresholdRequiredTypes lists alert types that require a threshold value.
|
||||
var ThresholdRequiredTypes = []AlertType{
|
||||
var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertLargeTransfer,
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
@@ -120,6 +123,9 @@ type NormalizedTx struct {
|
||||
// Direction indicates whether a transaction is incoming or outgoing.
|
||||
type Direction string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string { return string(d) }
|
||||
|
||||
// Direction constants indicate the flow of a transaction relative to a watched address.
|
||||
const (
|
||||
DirectionIncoming Direction = "incoming"
|
||||
|
||||
@@ -68,7 +68,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if !domain.IsValidAlertType(body.Type) {
|
||||
types := make([]string, len(domain.ValidAlertTypes))
|
||||
for i, t := range domain.ValidAlertTypes {
|
||||
types[i] = string(t)
|
||||
types[i] = t.String()
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
"Invalid alert type. Must be one of: "+strings.Join(types, ", "))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
@@ -97,7 +99,7 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
@@ -21,7 +23,7 @@ func (m *AlertRuleModel) Create(ctx context.Context, addressID int, alertType do
|
||||
`INSERT INTO alert_rules (address_id, type, threshold, enabled)
|
||||
VALUES ($1, $2, $3, TRUE)
|
||||
RETURNING id, address_id, type, threshold, enabled, created_at`,
|
||||
addressID, string(alertType), threshold,
|
||||
addressID, alertType.String(), threshold,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,7 +77,7 @@ func (m *AlertRuleModel) FindByID(ctx context.Context, id int, userID *string) (
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -93,7 +95,7 @@ func (m *AlertRuleModel) UpdateEnabled(ctx context.Context, id int, enabled bool
|
||||
id, enabled,
|
||||
).Scan(&r.ID, &r.AddressID, &r.Type, &r.Threshold, &r.Enabled, &r.CreatedAt)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
@@ -23,7 +25,7 @@ func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int
|
||||
addressID,
|
||||
).Scan(&block)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, err
|
||||
|
||||
@@ -2,7 +2,9 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
)
|
||||
@@ -26,7 +28,7 @@ func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string)
|
||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -9,6 +9,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// discordHTTPTimeoutSeconds is the timeout for Discord webhook requests.
|
||||
discordHTTPTimeoutSeconds = 10
|
||||
|
||||
// Alert color codes for Discord embeds.
|
||||
colorGreen = 0x00ff00
|
||||
colorOrange = 0xff9900
|
||||
colorRed = 0xff0000
|
||||
colorBlue = 0x0099ff
|
||||
)
|
||||
|
||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
@@ -73,7 +89,9 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
return false, fmt.Errorf("marshal discord payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
resp, err := discordHTTPClient.Post(
|
||||
webhookURL, "application/json", bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Discord notification: %v", err)
|
||||
return false, err
|
||||
@@ -98,7 +116,9 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
resp, err := discordHTTPClient.Post(
|
||||
webhookURL, "application/json", bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Discord webhook test failed: %v", err)
|
||||
return false, err
|
||||
@@ -111,14 +131,14 @@ func TestDiscordWebhook(webhookURL string) (bool, error) {
|
||||
func colorForAlertType(alertType string) int {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return 0x00ff00 // Green
|
||||
return colorGreen
|
||||
case "outgoing_tx":
|
||||
return 0xff9900 // Orange
|
||||
return colorOrange
|
||||
case "large_transfer":
|
||||
return 0xff0000 // Red
|
||||
return colorRed
|
||||
case "balance_below":
|
||||
return 0xff0000 // Red
|
||||
return colorRed
|
||||
default:
|
||||
return 0x0099ff // Blue
|
||||
return colorBlue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
|
||||
// Send Discord notification (non-fatal on failure)
|
||||
if addr != nil {
|
||||
go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address)
|
||||
go func() {
|
||||
s.sendNotification(
|
||||
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user