diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index cef0028..560ca72 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -13,10 +13,11 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/handlers" "github.com/kjannette/koin-ping/backend-go/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" ) func main() { - _ = godotenv.Load() // .env is optional; env vars can also be set externally + _ = godotenv.Load() cfg, err := config.Load() if err != nil { @@ -33,41 +34,47 @@ func main() { log.Fatalf("Failed to initialize Firebase: %v", err) } + var eth ethereum.EthereumObserver + if cfg.EthRPCURL != "" { + eth, err = ethereum.NewJsonRpcEthereum(cfg.EthRPCURL) + if err != nil { + log.Printf("Warning: Failed to create Ethereum observer: %v", err) + } + } + addressModel := models.NewAddressModel(pool) alertRuleModel := models.NewAlertRuleModel(pool) alertEventModel := models.NewAlertEventModel(pool) notifConfigModel := models.NewNotificationConfigModel(pool) + checkpointModel := models.NewCheckpointModel(pool) addressHandler := handlers.NewAddressHandler(addressModel) alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel) alertEventHandler := handlers.NewAlertEventHandler(alertEventModel) notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel) + statusHandler := handlers.NewStatusHandler(eth, checkpointModel) mux := http.NewServeMux() - b := cfg.APIBasePath // e.g. "/v1" + b := cfg.APIBasePath - // Public routes mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck) - mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus) + mux.HandleFunc("GET "+b+"/status", statusHandler.SystemStatus) - // Authenticated routes — addresses mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) - // Authenticated routes — alert rules mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) - // Authenticated routes — alert events mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) - // Authenticated routes — notification config mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) + mux.Handle("POST "+b+"/notification-config/test", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestWebhook))) handler := corsMiddleware(mux) diff --git a/backend-go/cmd/poller/main.go b/backend-go/cmd/poller/main.go index 9c941b8..a94581a 100644 --- a/backend-go/cmd/poller/main.go +++ b/backend-go/cmd/poller/main.go @@ -14,6 +14,7 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/config" "github.com/kjannette/koin-ping/backend-go/internal/database" "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/notifications" "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" "github.com/kjannette/koin-ping/backend-go/internal/services" ) @@ -47,8 +48,16 @@ func main() { checkpointModel := models.NewCheckpointModel(pool) notifConfigModel := models.NewNotificationConfigModel(pool) + smtpCfg := notifications.SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + Username: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.SMTPFrom, + } + observer := services.NewObserverService(eth, addressModel, checkpointModel) - evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel) + evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel, smtpCfg) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/backend-go/infra/migrations/003_replace_telegram_with_slack.sql b/backend-go/infra/migrations/003_replace_telegram_with_slack.sql new file mode 100644 index 0000000..8808d94 --- /dev/null +++ b/backend-go/infra/migrations/003_replace_telegram_with_slack.sql @@ -0,0 +1,4 @@ +ALTER TABLE user_notification_configs + DROP COLUMN IF EXISTS telegram_chat_id, + DROP COLUMN IF EXISTS telegram_bot_token, + ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT; diff --git a/backend-go/infra/schema.sql b/backend-go/infra/schema.sql index 92cf66e..e422b0f 100644 --- a/backend-go/infra/schema.sql +++ b/backend-go/infra/schema.sql @@ -53,8 +53,7 @@ CREATE TABLE address_checkpoints ( CREATE TABLE user_notification_configs ( user_id VARCHAR(128) PRIMARY KEY, discord_webhook_url TEXT, -- Discord webhook URL (nullable) - telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable) - telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use) + slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable) email VARCHAR(255), -- Email for notifications (nullable) notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch created_at TIMESTAMP DEFAULT NOW(), diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go index 29f969b..a07377c 100644 --- a/backend-go/internal/config/config.go +++ b/backend-go/internal/config/config.go @@ -20,6 +20,12 @@ type Config struct { EthRPCURL string PollIntervalMS int NodeEnv string + + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPFrom string } func Load() (*Config, error) { @@ -36,6 +42,12 @@ func Load() (*Config, error) { EthRPCURL: os.Getenv("ETH_RPC_URL"), PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), NodeEnv: getEnv("NODE_ENV", "development"), + + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: getEnvInt("SMTP_PORT", 587), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPassword: os.Getenv("SMTP_PASSWORD"), + SMTPFrom: os.Getenv("SMTP_FROM"), } if cfg.PollIntervalMS < 1000 { diff --git a/backend-go/internal/config/config_test.go b/backend-go/internal/config/config_test.go new file mode 100644 index 0000000..72a6b06 --- /dev/null +++ b/backend-go/internal/config/config_test.go @@ -0,0 +1,115 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestLoad_Defaults(t *testing.T) { + for _, key := range []string{"PORT", "API_BASE_PATH", "POLL_INTERVAL_MS", "NODE_ENV", "SMTP_PORT"} { + os.Unsetenv(key) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Port != 3001 { + t.Errorf("Port = %d, want 3001", cfg.Port) + } + if cfg.APIBasePath != "/v1" { + t.Errorf("APIBasePath = %q, want /v1", cfg.APIBasePath) + } + if cfg.PollIntervalMS != 60000 { + t.Errorf("PollIntervalMS = %d, want 60000", cfg.PollIntervalMS) + } + if cfg.NodeEnv != "development" { + t.Errorf("NodeEnv = %q, want development", cfg.NodeEnv) + } + if cfg.SMTPPort != 587 { + t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort) + } +} + +func TestLoad_InvalidPollInterval(t *testing.T) { + os.Setenv("POLL_INTERVAL_MS", "500") + defer os.Unsetenv("POLL_INTERVAL_MS") + + _, err := Load() + if err == nil { + t.Fatal("expected error for poll interval < 1000") + } +} + +func TestDSN_DatabaseURL(t *testing.T) { + cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db"} + dsn := cfg.DSN() + if !strings.Contains(dsn, "sslmode=disable") { + t.Error("DSN should append sslmode=disable") + } + if !strings.HasPrefix(dsn, "postgres://user:pass@localhost/db") { + t.Error("DSN should preserve original URL") + } +} + +func TestDSN_DatabaseURL_WithSSLMode(t *testing.T) { + cfg := &Config{DatabaseURL: "postgres://user:pass@localhost/db?sslmode=require"} + dsn := cfg.DSN() + if strings.Count(dsn, "sslmode=") != 1 { + t.Error("DSN should not duplicate sslmode") + } +} + +func TestDSN_Components(t *testing.T) { + cfg := &Config{ + DBHost: "myhost", + DBPort: 5433, + DBUser: "myuser", + DBPassword: "mypass", + DBName: "mydb", + } + dsn := cfg.DSN() + if !strings.Contains(dsn, "host=myhost") { + t.Error("DSN should contain host") + } + if !strings.Contains(dsn, "port=5433") { + t.Error("DSN should contain port") + } + if !strings.Contains(dsn, "user=myuser") { + t.Error("DSN should contain user") + } + if !strings.Contains(dsn, "dbname=mydb") { + t.Error("DSN should contain dbname") + } +} + +func TestGetEnv(t *testing.T) { + os.Setenv("TEST_KEY_KOINPING", "value") + defer os.Unsetenv("TEST_KEY_KOINPING") + + if got := getEnv("TEST_KEY_KOINPING", "fallback"); got != "value" { + t.Errorf("got %q, want %q", got, "value") + } + if got := getEnv("NONEXISTENT_KEY_KOINPING", "fallback"); got != "fallback" { + t.Errorf("got %q, want %q", got, "fallback") + } +} + +func TestGetEnvInt(t *testing.T) { + os.Setenv("TEST_INT_KOINPING", "42") + defer os.Unsetenv("TEST_INT_KOINPING") + + if got := getEnvInt("TEST_INT_KOINPING", 0); got != 42 { + t.Errorf("got %d, want 42", got) + } + if got := getEnvInt("NONEXISTENT_INT_KOINPING", 99); got != 99 { + t.Errorf("got %d, want 99", got) + } + + os.Setenv("TEST_INT_KOINPING", "notanumber") + if got := getEnvInt("TEST_INT_KOINPING", 99); got != 99 { + t.Errorf("got %d, want 99 for invalid int", got) + } +} diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go index d0ded03..3141d16 100644 --- a/backend-go/internal/domain/types.go +++ b/backend-go/internal/domain/types.go @@ -82,12 +82,11 @@ type CheckpointDetail struct { } type NotificationConfig struct { - UserID string `json:"user_id"` - DiscordWebhookURL *string `json:"discord_webhook_url"` - TelegramChatID *string `json:"telegram_chat_id"` - TelegramBotToken *string `json:"telegram_bot_token,omitempty"` - Email *string `json:"email"` - NotificationEnabled bool `json:"notification_enabled"` + UserID string `json:"user_id"` + DiscordWebhookURL *string `json:"discord_webhook_url"` + SlackWebhookURL *string `json:"slack_webhook_url"` + Email *string `json:"email"` + NotificationEnabled bool `json:"notification_enabled"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } diff --git a/backend-go/internal/domain/types_test.go b/backend-go/internal/domain/types_test.go new file mode 100644 index 0000000..5c0b1d3 --- /dev/null +++ b/backend-go/internal/domain/types_test.go @@ -0,0 +1,35 @@ +package domain + +import "testing" + +func TestIsValidAlertType(t *testing.T) { + valid := []string{"incoming_tx", "outgoing_tx", "large_transfer", "balance_below"} + for _, v := range valid { + if !IsValidAlertType(v) { + t.Errorf("IsValidAlertType(%q) = false, want true", v) + } + } + + invalid := []string{"", "invalid", "INCOMING_TX", "send", "receive"} + for _, v := range invalid { + if IsValidAlertType(v) { + t.Errorf("IsValidAlertType(%q) = true, want false", v) + } + } +} + +func TestIsThresholdRequired(t *testing.T) { + required := []AlertType{AlertLargeTransfer, AlertBalanceBelow} + for _, at := range required { + if !IsThresholdRequired(at) { + t.Errorf("IsThresholdRequired(%q) = false, want true", at) + } + } + + notRequired := []AlertType{AlertIncomingTx, AlertOutgoingTx} + for _, at := range notRequired { + if IsThresholdRequired(at) { + t.Errorf("IsThresholdRequired(%q) = true, want false", at) + } + } +} diff --git a/backend-go/internal/handlers/alert_event.go b/backend-go/internal/handlers/alert_event.go index 598e923..6e0e5a4 100644 --- a/backend-go/internal/handlers/alert_event.go +++ b/backend-go/internal/handlers/alert_event.go @@ -4,9 +4,7 @@ import ( "log" "net/http" "strconv" - "time" - "github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/models" ) @@ -46,44 +44,5 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) { log.Printf("Found %d alert events for user", len(events)) - // MVP scaffolding: return mock data if DB is empty - if len(events) == 0 { - events = mockEvents(limit) - } - writeJSON(w, http.StatusOK, events) } - -func mockEvents(limit int) []domain.AlertEvent { - label1 := "Treasury Wallet" - label2 := "Cold Storage" - - mocks := []domain.AlertEvent{ - { - ID: 1, - AlertRuleID: 1, - Message: "Incoming transaction detected: 5.5 ETH received", - AddressLabel: &label1, - Timestamp: time.Now().Add(-2 * time.Hour), - }, - { - ID: 2, - AlertRuleID: 2, - Message: "Balance dropped below threshold: Current balance 8.2 ETH", - AddressLabel: &label1, - Timestamp: time.Now().Add(-5 * time.Hour), - }, - { - ID: 3, - AlertRuleID: 3, - Message: "Outgoing transaction detected: 2.0 ETH sent", - AddressLabel: &label2, - Timestamp: time.Now().Add(-24 * time.Hour), - }, - } - - if limit < len(mocks) { - return mocks[:limit] - } - return mocks -} diff --git a/backend-go/internal/handlers/notification_config.go b/backend-go/internal/handlers/notification_config.go index d39c534..6e77130 100644 --- a/backend-go/internal/handlers/notification_config.go +++ b/backend-go/internal/handlers/notification_config.go @@ -10,6 +10,7 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/domain" "github.com/kjannette/koin-ping/backend-go/internal/middleware" "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/notifications" ) var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) @@ -50,7 +51,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http. var body struct { DiscordWebhookURL *string `json:"discord_webhook_url"` - TelegramChatID *string `json:"telegram_chat_id"` + SlackWebhookURL *string `json:"slack_webhook_url"` Email *string `json:"email"` NotificationEnabled *bool `json:"notification_enabled"` } @@ -62,7 +63,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http. log.Printf("User %s updating notification config", userID) - if body.DiscordWebhookURL == nil && body.TelegramChatID == nil && + if body.DiscordWebhookURL == nil && body.SlackWebhookURL == nil && body.Email == nil && body.NotificationEnabled == nil { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "At least one configuration field must be provided") @@ -76,6 +77,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http. return } + if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" && + !strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid Slack webhook URL format") + return + } + if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid email address format") @@ -89,7 +97,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http. cfg := domain.NotificationConfig{ DiscordWebhookURL: body.DiscordWebhookURL, - TelegramChatID: body.TelegramChatID, + SlackWebhookURL: body.SlackWebhookURL, Email: body.Email, NotificationEnabled: enabled, } @@ -126,3 +134,50 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http. log.Println("Notification config deleted") w.WriteHeader(http.StatusNoContent) } + +func (h *NotificationConfigHandler) TestWebhook(w http.ResponseWriter, r *http.Request) { + _ = middleware.GetUserID(r.Context()) + + var body struct { + Type string `json:"type"` + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return + } + + if body.URL == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "URL is required") + return + } + + var ok bool + var err error + + switch body.Type { + case "slack": + if !strings.HasPrefix(body.URL, "https://hooks.slack.com/") { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Slack webhook URL") + return + } + ok, err = notifications.TestSlackWebhook(body.URL) + case "discord": + if !strings.HasPrefix(body.URL, "https://discord.com/api/webhooks/") { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Discord webhook URL") + return + } + ok, err = notifications.TestDiscordWebhook(body.URL) + default: + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Type must be 'slack' or 'discord'") + return + } + + if err != nil { + log.Printf("Webhook test failed: %v", err) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"success": ok}) +} diff --git a/backend-go/internal/handlers/status.go b/backend-go/internal/handlers/status.go index 621adaa..74ebb99 100644 --- a/backend-go/internal/handlers/status.go +++ b/backend-go/internal/handlers/status.go @@ -1,8 +1,13 @@ package handlers import ( + "context" + "log" "net/http" "time" + + "github.com/kjannette/koin-ping/backend-go/internal/models" + "github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum" ) func HealthCheck(w http.ResponseWriter, r *http.Request) { @@ -13,11 +18,62 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) { }) } -func SystemStatus(w http.ResponseWriter, r *http.Request) { +type StatusHandler struct { + eth ethereum.EthereumObserver + checkpoints *models.CheckpointModel +} + +func NewStatusHandler(eth ethereum.EthereumObserver, checkpoints *models.CheckpointModel) *StatusHandler { + return &StatusHandler{eth: eth, checkpoints: checkpoints} +} + +func (h *StatusHandler) SystemStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + status := "healthy" + + if h.eth == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "latestBlock": 0, + "lag": 0, + "trackedAddresses": 0, + "status": "no_rpc", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + return + } + + latestBlock, err := h.eth.GetLatestBlockNumber(ctx) + if err != nil { + log.Printf("Failed to get latest block: %v", err) + status = "degraded" + latestBlock = 0 + } + + lag := 0 + checkpoints, err := h.checkpoints.ListAll(ctx) + if err != nil { + log.Printf("Failed to list checkpoints: %v", err) + } else if len(checkpoints) > 0 && latestBlock > 0 { + minChecked := checkpoints[0].LastCheckedBlock + for _, cp := range checkpoints[1:] { + if cp.LastCheckedBlock < minChecked { + minChecked = cp.LastCheckedBlock + } + } + lag = latestBlock - minChecked + } + + if lag > 50 { + status = "syncing" + } + writeJSON(w, http.StatusOK, map[string]interface{}{ - "latestBlock": 0, - "lag": 0, - "status": "healthy", - "timestamp": time.Now().UTC().Format(time.RFC3339), + "latestBlock": latestBlock, + "lag": lag, + "trackedAddresses": len(checkpoints), + "status": status, + "timestamp": time.Now().UTC().Format(time.RFC3339), }) } diff --git a/backend-go/internal/models/notification_config.go b/backend-go/internal/models/notification_config.go index 7e6b175..601b337 100644 --- a/backend-go/internal/models/notification_config.go +++ b/backend-go/internal/models/notification_config.go @@ -18,12 +18,12 @@ func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel { func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) { var c domain.NotificationConfig err := m.pool.QueryRow(ctx, - `SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, + `SELECT user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, created_at, updated_at FROM user_notification_configs WHERE user_id = $1`, userID, - ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, + ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) if err != nil { if err.Error() == "no rows in result set" { @@ -38,21 +38,20 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin var c domain.NotificationConfig err := m.pool.QueryRow(ctx, `INSERT INTO user_notification_configs - (user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) + (user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (user_id) DO UPDATE SET discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url), - telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id), - telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token), - email = COALESCE($5, user_notification_configs.email), - notification_enabled = $6, + slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url), + email = COALESCE($4, user_notification_configs.email), + notification_enabled = $5, updated_at = NOW() - RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, + RETURNING user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, created_at, updated_at`, - userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken, + userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL, cfg.Email, cfg.NotificationEnabled, - ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken, + ).Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt) if err != nil { return nil, err @@ -73,7 +72,7 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) { rows, err := m.pool.Query(ctx, - `SELECT user_id, discord_webhook_url, telegram_chat_id, email + `SELECT user_id, discord_webhook_url, slack_webhook_url, email FROM user_notification_configs WHERE notification_enabled = TRUE`, ) @@ -85,7 +84,7 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not var configs []domain.NotificationConfig for rows.Next() { var c domain.NotificationConfig - if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.Email); err != nil { + if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.SlackWebhookURL, &c.Email); err != nil { return nil, err } c.NotificationEnabled = true diff --git a/backend-go/internal/notifications/discord_test.go b/backend-go/internal/notifications/discord_test.go new file mode 100644 index 0000000..f5fed8d --- /dev/null +++ b/backend-go/internal/notifications/discord_test.go @@ -0,0 +1,99 @@ +package notifications + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendDiscordNotification(t *testing.T) { + var receivedPayload discordPayload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedPayload) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + meta := AlertMetadata{ + TxHash: "0xabc123", + AddressLabel: "Treasury", + AlertType: "incoming_tx", + Address: "0x1234567890abcdef1234567890abcdef12345678", + } + + sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !sent { + t.Fatal("expected sent to be true") + } + + if len(receivedPayload.Embeds) != 1 { + t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds)) + } + embed := receivedPayload.Embeds[0] + if embed.Title != "Koin Ping Alert" { + t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert") + } + if embed.Color != 0x00ff00 { + t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00) + } + if len(embed.Fields) != 3 { + t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields)) + } +} + +func TestSendDiscordNotification_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{}) + if err == nil { + t.Fatal("expected error for bad response") + } + if sent { + t.Fatal("expected sent to be false") + } +} + +func TestTestDiscordWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + ok, err := TestDiscordWebhook(server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected ok to be true") + } +} + +func TestColorForAlertType(t *testing.T) { + tests := map[string]int{ + "incoming_tx": 0x00ff00, + "outgoing_tx": 0xff9900, + "large_transfer": 0xff0000, + "balance_below": 0xff0000, + "unknown": 0x0099ff, + } + for alertType, want := range tests { + if got := colorForAlertType(alertType); got != want { + t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want) + } + } +} diff --git a/backend-go/internal/notifications/email.go b/backend-go/internal/notifications/email.go new file mode 100644 index 0000000..3f68ede --- /dev/null +++ b/backend-go/internal/notifications/email.go @@ -0,0 +1,111 @@ +package notifications + +import ( + "fmt" + "log" + "net/smtp" + "strings" +) + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + From string +} + +func (c *SMTPConfig) IsConfigured() bool { + return c.Host != "" && c.From != "" +} + +func (c *SMTPConfig) addr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + +func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) { + if !cfg.IsConfigured() { + return false, fmt.Errorf("SMTP is not configured") + } + + subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType)) + + body := buildEmailBody(message, meta) + + msg := strings.Join([]string{ + fmt.Sprintf("From: %s", cfg.From), + fmt.Sprintf("To: %s", toEmail), + fmt.Sprintf("Subject: %s", subject), + "MIME-Version: 1.0", + "Content-Type: text/html; charset=UTF-8", + "", + body, + }, "\r\n") + + var auth smtp.Auth + if cfg.Username != "" { + auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + } + + err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg)) + if err != nil { + log.Printf("Failed to send email notification to %s: %v", toEmail, err) + return false, err + } + + return true, nil +} + +func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) { + meta := AlertMetadata{AlertType: "test"} + return SendEmailNotification(cfg, toEmail, + "This is a test notification from Koin Ping. Your email is configured correctly!", meta) +} + +func buildEmailBody(message string, meta AlertMetadata) string { + txSection := "" + if meta.TxHash != "" { + txSection = fmt.Sprintf( + `Transaction`+ + `View on Etherscan`, + meta.TxHash, + ) + } + + return fmt.Sprintf(` + + +
+
+

Koin Ping Alert

+
+
+

%s

+ + + + %s +
Address%s
Blockchain Address%s
+
+
+ Sent by Koin Ping +
+
+ +`, message, meta.AddressLabel, meta.Address, txSection) +} + +func humanAlertType(alertType string) string { + switch alertType { + case "incoming_tx": + return "Incoming Transaction" + case "outgoing_tx": + return "Outgoing Transaction" + case "large_transfer": + return "Large Transfer" + case "balance_below": + return "Balance Below Threshold" + default: + return "Alert" + } +} diff --git a/backend-go/internal/notifications/email_test.go b/backend-go/internal/notifications/email_test.go new file mode 100644 index 0000000..baf6a0e --- /dev/null +++ b/backend-go/internal/notifications/email_test.go @@ -0,0 +1,102 @@ +package notifications + +import ( + "strings" + "testing" +) + +func TestSMTPConfig_IsConfigured(t *testing.T) { + tests := []struct { + name string + cfg SMTPConfig + want bool + }{ + {"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true}, + {"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false}, + {"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false}, + {"empty", SMTPConfig{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.IsConfigured(); got != tt.want { + t.Errorf("IsConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSMTPConfig_Addr(t *testing.T) { + cfg := SMTPConfig{Host: "smtp.example.com", Port: 587} + if got := cfg.addr(); got != "smtp.example.com:587" { + t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587") + } +} + +func TestBuildEmailBody(t *testing.T) { + meta := AlertMetadata{ + TxHash: "0xabc123", + AddressLabel: "Treasury", + AlertType: "incoming_tx", + Address: "0x1234567890abcdef1234567890abcdef12345678", + } + + body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta) + + if !strings.Contains(body, "Koin Ping Alert") { + t.Error("body should contain title") + } + if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") { + t.Error("body should contain message") + } + if !strings.Contains(body, "Treasury") { + t.Error("body should contain address label") + } + if !strings.Contains(body, "0x1234567890abcdef") { + t.Error("body should contain blockchain address") + } + if !strings.Contains(body, "etherscan.io/tx/0xabc123") { + t.Error("body should contain etherscan link") + } +} + +func TestBuildEmailBody_NoTxHash(t *testing.T) { + meta := AlertMetadata{ + AddressLabel: "Cold Storage", + AlertType: "balance_below", + Address: "0xabcdef", + } + + body := buildEmailBody("Balance dropped below threshold", meta) + + if strings.Contains(body, "etherscan.io") { + t.Error("body should not contain etherscan link when no tx hash") + } +} + +func TestHumanAlertType(t *testing.T) { + tests := map[string]string{ + "incoming_tx": "Incoming Transaction", + "outgoing_tx": "Outgoing Transaction", + "large_transfer": "Large Transfer", + "balance_below": "Balance Below Threshold", + "unknown": "Alert", + "test": "Alert", + } + for alertType, want := range tests { + if got := humanAlertType(alertType); got != want { + t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want) + } + } +} + +func TestSendEmailNotification_NotConfigured(t *testing.T) { + cfg := SMTPConfig{} + sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{}) + if err == nil { + t.Fatal("expected error for unconfigured SMTP") + } + if sent { + t.Fatal("expected sent to be false") + } +} diff --git a/backend-go/internal/notifications/slack.go b/backend-go/internal/notifications/slack.go new file mode 100644 index 0000000..83bd417 --- /dev/null +++ b/backend-go/internal/notifications/slack.go @@ -0,0 +1,128 @@ +package notifications + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type slackBlock struct { + Type string `json:"type"` + Text *slackText `json:"text,omitempty"` + Fields []slackText `json:"fields,omitempty"` + Elements []slackText `json:"elements,omitempty"` +} + +type slackText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type slackPayload struct { + Blocks []slackBlock `json:"blocks"` +} + +func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) { + etherscanLink := "" + if meta.TxHash != "" { + etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash) + } + + blocks := []slackBlock{ + { + Type: "header", + Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"}, + }, + { + Type: "section", + Text: &slackText{Type: "mrkdwn", Text: message}, + }, + { + Type: "section", + Fields: []slackText{ + {Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)}, + {Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)}, + }, + }, + } + + if etherscanLink != "" { + blocks = append(blocks, slackBlock{ + Type: "section", + Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)}, + }) + } + + blocks = append(blocks, slackBlock{ + Type: "context", + Elements: []slackText{ + {Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))}, + }, + }) + + payload := slackPayload{Blocks: blocks} + body, err := json.Marshal(payload) + if err != nil { + return false, fmt.Errorf("marshal slack payload: %w", err) + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("Failed to send Slack notification: %v", err) + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode) + return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode) + } + + return true, nil +} + +func TestSlackWebhook(webhookURL string) (bool, error) { + payload := slackPayload{ + Blocks: []slackBlock{ + { + Type: "section", + Text: &slackText{ + Type: "mrkdwn", + Text: "Koin Ping test notification — Your Slack webhook is configured correctly!", + }, + }, + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return false, err + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("Slack webhook test failed: %v", err) + return false, err + } + defer resp.Body.Close() + + return resp.StatusCode >= 200 && resp.StatusCode < 300, nil +} + +func emojiForAlertType(alertType string) string { + switch alertType { + case "incoming_tx": + return "📥" + case "outgoing_tx": + return "📤" + case "large_transfer": + return "🚨" + case "balance_below": + return "⚠️" + default: + return "🔔" + } +} diff --git a/backend-go/internal/notifications/slack_test.go b/backend-go/internal/notifications/slack_test.go new file mode 100644 index 0000000..55f1c8a --- /dev/null +++ b/backend-go/internal/notifications/slack_test.go @@ -0,0 +1,128 @@ +package notifications + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendSlackNotification(t *testing.T) { + var receivedPayload slackPayload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("expected application/json content type") + } + + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedPayload) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + meta := AlertMetadata{ + TxHash: "0xabc123", + AddressLabel: "Treasury", + AlertType: "incoming_tx", + Address: "0x1234567890abcdef1234567890abcdef12345678", + } + + sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !sent { + t.Fatal("expected sent to be true") + } + + if len(receivedPayload.Blocks) < 3 { + t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks)) + } + if receivedPayload.Blocks[0].Type != "header" { + t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type) + } +} + +func TestSendSlackNotification_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{}) + if err == nil { + t.Fatal("expected error for server error response") + } + if sent { + t.Fatal("expected sent to be false") + } +} + +func TestSendSlackNotification_NoTxHash(t *testing.T) { + var receivedPayload slackPayload + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &receivedPayload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + meta := AlertMetadata{ + AddressLabel: "Treasury", + AlertType: "balance_below", + Address: "0x1234567890abcdef1234567890abcdef12345678", + } + + sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !sent { + t.Fatal("expected sent to be true") + } + + for _, block := range receivedPayload.Blocks { + if block.Text != nil && block.Type == "section" { + if block.Text.Text == "*Transaction:*" { + t.Error("should not include transaction block when TxHash is empty") + } + } + } +} + +func TestTestSlackWebhook(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ok, err := TestSlackWebhook(server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected ok to be true") + } +} + +func TestEmojiForAlertType(t *testing.T) { + tests := map[string]string{ + "incoming_tx": "📥", + "outgoing_tx": "📤", + "large_transfer": "🚨", + "balance_below": "⚠️", + "unknown": "🔔", + } + for alertType, want := range tests { + if got := emojiForAlertType(alertType); got != want { + t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want) + } + } +} diff --git a/backend-go/internal/protocols/ethereum/jsonrpc_test.go b/backend-go/internal/protocols/ethereum/jsonrpc_test.go new file mode 100644 index 0000000..026d474 --- /dev/null +++ b/backend-go/internal/protocols/ethereum/jsonrpc_test.go @@ -0,0 +1,96 @@ +package ethereum + +import ( + "math/big" + "testing" +) + +func TestHexToInt(t *testing.T) { + tests := []struct { + hex string + want int + }{ + {"0x0", 0}, + {"0x1", 1}, + {"0xa", 10}, + {"0xff", 255}, + {"0x100", 256}, + {"0x1234", 4660}, + } + + for _, tt := range tests { + got, err := hexToInt(tt.hex) + if err != nil { + t.Fatalf("hexToInt(%q) error: %v", tt.hex, err) + } + if got != tt.want { + t.Errorf("hexToInt(%q) = %d, want %d", tt.hex, got, tt.want) + } + } +} + +func TestHexToInt_Invalid(t *testing.T) { + _, err := hexToInt("xyz") + if err == nil { + t.Fatal("expected error for invalid hex") + } +} + +func TestHexToInt64(t *testing.T) { + got, err := hexToInt64("0x5f5e100") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 100000000 { + t.Errorf("got %d, want 100000000", got) + } +} + +func TestHexToDecimalString(t *testing.T) { + tests := []struct { + hex string + want string + }{ + {"0x0", "0"}, + {"0x", "0"}, + {"", "0"}, + {"0x1", "1"}, + {"0xde0b6b3a7640000", "1000000000000000000"}, + } + + for _, tt := range tests { + got := hexToDecimalString(tt.hex) + if got != tt.want { + t.Errorf("hexToDecimalString(%q) = %q, want %q", tt.hex, got, tt.want) + } + } +} + +func TestHexToDecimalString_LargeValues(t *testing.T) { + expected := new(big.Int).Mul( + big.NewInt(100), + new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + ) + hex := "0x" + expected.Text(16) + got := hexToDecimalString(hex) + if got != expected.String() { + t.Errorf("got %q, want %q", got, expected.String()) + } +} + +func TestNewJsonRpcEthereum_EmptyURL(t *testing.T) { + _, err := NewJsonRpcEthereum("") + if err == nil { + t.Fatal("expected error for empty URL") + } +} + +func TestNewJsonRpcEthereum_ValidURL(t *testing.T) { + eth, err := NewJsonRpcEthereum("https://example.com/rpc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if eth == nil { + t.Fatal("expected non-nil client") + } +} diff --git a/backend-go/internal/services/evaluator.go b/backend-go/internal/services/evaluator.go index 9dfc9b2..a68529f 100644 --- a/backend-go/internal/services/evaluator.go +++ b/backend-go/internal/services/evaluator.go @@ -18,6 +18,7 @@ type EvaluatorService struct { alertEvents *models.AlertEventModel addresses *models.AddressModel notifConfigs *models.NotificationConfigModel + smtpConfig notifications.SMTPConfig } func NewEvaluatorService( @@ -26,6 +27,7 @@ func NewEvaluatorService( alertEvents *models.AlertEventModel, addresses *models.AddressModel, notifConfigs *models.NotificationConfigModel, + smtpCfg notifications.SMTPConfig, ) *EvaluatorService { return &EvaluatorService{ eth: eth, @@ -33,6 +35,7 @@ func NewEvaluatorService( alertEvents: alertEvents, addresses: addresses, notifConfigs: notifConfigs, + smtpConfig: smtpCfg, } } @@ -168,40 +171,56 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule, log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash) - // Send Discord notification (non-fatal on failure) if addr != nil { - go s.sendNotification(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address) + go s.sendNotifications(ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address) } return nil } -func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) { +func (s *EvaluatorService) sendNotifications(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) { notifConfig, err := s.notifConfigs.GetConfig(ctx, userID) if err != nil { log.Printf("Failed to get notification config: %v", err) return } - if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil { + if notifConfig == nil || !notifConfig.NotificationEnabled { return } - sent, err := notifications.SendDiscordNotification( - *notifConfig.DiscordWebhookURL, - message, - notifications.AlertMetadata{ - TxHash: obs.Hash, - AddressLabel: addressLabel, - AlertType: string(rule.Type), - Address: address, - }, - ) + meta := notifications.AlertMetadata{ + TxHash: obs.Hash, + AddressLabel: addressLabel, + AlertType: string(rule.Type), + Address: address, + } - if err != nil || !sent { - log.Printf("Discord notification failed for user %s: %v", userID, err) - } else { - log.Printf("Discord notification sent to user %s", userID) + if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" { + sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta) + if err != nil || !sent { + log.Printf("Discord notification failed for user %s: %v", userID, err) + } else { + log.Printf("Discord notification sent to user %s", userID) + } + } + + if notifConfig.SlackWebhookURL != nil && *notifConfig.SlackWebhookURL != "" { + sent, err := notifications.SendSlackNotification(*notifConfig.SlackWebhookURL, message, meta) + if err != nil || !sent { + log.Printf("Slack notification failed for user %s: %v", userID, err) + } else { + log.Printf("Slack notification sent to user %s", userID) + } + } + + if notifConfig.Email != nil && *notifConfig.Email != "" && s.smtpConfig.IsConfigured() { + sent, err := notifications.SendEmailNotification(s.smtpConfig, *notifConfig.Email, message, meta) + if err != nil || !sent { + log.Printf("Email notification failed for user %s: %v", userID, err) + } else { + log.Printf("Email notification sent to user %s (%s)", userID, *notifConfig.Email) + } } } diff --git a/backend-go/internal/services/observer_test.go b/backend-go/internal/services/observer_test.go new file mode 100644 index 0000000..d5251cd --- /dev/null +++ b/backend-go/internal/services/observer_test.go @@ -0,0 +1,113 @@ +package services + +import ( + "testing" + + "github.com/kjannette/koin-ping/backend-go/internal/domain" +) + +func TestFilterRelevantTransactions(t *testing.T) { + to1 := "0xaaaa" + to2 := "0xbbbb" + to3 := "0xcccc" + + txs := []domain.NormalizedTx{ + {Hash: "0x1", From: "0xaaaa", To: &to2, Value: "1000"}, + {Hash: "0x2", From: "0xbbbb", To: &to1, Value: "2000"}, + {Hash: "0x3", From: "0xcccc", To: &to3, Value: "3000"}, + {Hash: "0x4", From: "0xdddd", To: nil, Value: "0"}, + } + + t.Run("finds outgoing", func(t *testing.T) { + result := filterRelevantTransactions(txs, "0xAAAA") + if len(result) != 2 { + t.Fatalf("expected 2 relevant txs (1 from, 1 to), got %d", len(result)) + } + }) + + t.Run("finds incoming", func(t *testing.T) { + result := filterRelevantTransactions(txs, "0xBBBB") + if len(result) != 2 { + t.Fatalf("expected 2 relevant txs, got %d", len(result)) + } + }) + + t.Run("no match", func(t *testing.T) { + result := filterRelevantTransactions(txs, "0x9999") + if len(result) != 0 { + t.Fatalf("expected 0 relevant txs, got %d", len(result)) + } + }) + + t.Run("case insensitive", func(t *testing.T) { + result := filterRelevantTransactions(txs, "0xAAAA") + if len(result) == 0 { + t.Fatal("should match case-insensitively") + } + }) +} + +func TestCreateObservedTx(t *testing.T) { + to := "0xaaaa" + tx := domain.NormalizedTx{ + Hash: "0xhash", + From: "0xbbbb", + To: &to, + Value: "1000", + } + + t.Run("incoming direction", func(t *testing.T) { + addr := domain.Address{ID: 1, Address: "0xaaaa"} + obs := createObservedTx(tx, addr) + if obs.Direction != domain.DirectionIncoming { + t.Errorf("expected incoming, got %s", obs.Direction) + } + if obs.AddressID != 1 { + t.Errorf("expected address ID 1, got %d", obs.AddressID) + } + }) + + t.Run("outgoing direction", func(t *testing.T) { + addr := domain.Address{ID: 2, Address: "0xbbbb"} + obs := createObservedTx(tx, addr) + if obs.Direction != domain.DirectionOutgoing { + t.Errorf("expected outgoing, got %s", obs.Direction) + } + }) +} + +func TestGetStartBlock(t *testing.T) { + s := &ObserverService{} + + t.Run("no checkpoint uses latest", func(t *testing.T) { + got := s.getStartBlock(0, false, 1000) + if got != 1000 { + t.Errorf("expected 1000, got %d", got) + } + }) + + t.Run("with checkpoint uses next block", func(t *testing.T) { + got := s.getStartBlock(999, true, 1000) + if got != 1000 { + t.Errorf("expected 1000, got %d", got) + } + }) +} + +func TestGetEndBlock(t *testing.T) { + s := &ObserverService{} + + t.Run("caps at latest", func(t *testing.T) { + got := s.getEndBlock(990, 1000) + if got != 1000 { + t.Errorf("expected 1000, got %d", got) + } + }) + + t.Run("caps at maxBlocksPerRun", func(t *testing.T) { + got := s.getEndBlock(0, 200) + if got != maxBlocksPerRun-1 { + t.Errorf("expected %d, got %d", maxBlocksPerRun-1, got) + } + }) +} diff --git a/backend-go/internal/wei/converter_test.go b/backend-go/internal/wei/converter_test.go new file mode 100644 index 0000000..36043ca --- /dev/null +++ b/backend-go/internal/wei/converter_test.go @@ -0,0 +1,179 @@ +package wei + +import ( + "math" + "testing" +) + +func TestToEth(t *testing.T) { + tests := []struct { + name string + wei string + want float64 + wantErr bool + }{ + {"zero string", "0", 0, false}, + {"empty string", "", 0, false}, + {"1 ETH", "1000000000000000000", 1.0, false}, + {"0.5 ETH", "500000000000000000", 0.5, false}, + {"10 ETH", "10000000000000000000", 10.0, false}, + {"small amount", "1000000000000000", 0.001, false}, + {"1 Wei", "1", 1e-18, false}, + {"invalid", "notanumber", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToEth(tt.wei) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if math.Abs(got-tt.want) > 1e-10 { + t.Fatalf("ToEth(%q) = %v, want %v", tt.wei, got, tt.want) + } + }) + } +} + +func TestFromEth(t *testing.T) { + tests := []struct { + name string + eth float64 + want string + wantErr bool + }{ + {"zero", 0, "0", false}, + {"1 ETH", 1.0, "1000000000000000000", false}, + {"0.5 ETH", 0.5, "500000000000000000", false}, + {"10 ETH", 10.0, "10000000000000000000", false}, + {"0.001 ETH", 0.001, "1000000000000000", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FromEth(tt.eth) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("FromEth(%v) = %q, want %q", tt.eth, got, tt.want) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + values := []float64{0.001, 0.1, 1.0, 5.5, 10.0, 100.0} + for _, eth := range values { + weiStr, err := FromEth(eth) + if err != nil { + t.Fatalf("FromEth(%v) error: %v", eth, err) + } + back, err := ToEth(weiStr) + if err != nil { + t.Fatalf("ToEth(%q) error: %v", weiStr, err) + } + if math.Abs(back-eth) > 1e-10 { + t.Fatalf("round-trip %v → %q → %v (diff: %v)", eth, weiStr, back, back-eth) + } + } +} + +func TestCompare(t *testing.T) { + tests := []struct { + name string + a, b string + want int + }{ + {"equal", "1000", "1000", 0}, + {"a > b", "2000", "1000", 1}, + {"a < b", "1000", "2000", -1}, + {"zero equal", "0", "0", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Compare(tt.a, tt.b) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("Compare(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestGreaterThanOrEqual(t *testing.T) { + ok, err := GreaterThanOrEqual("2000", "1000") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected true") + } + + ok, err = GreaterThanOrEqual("1000", "1000") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected true for equal") + } + + ok, err = GreaterThanOrEqual("500", "1000") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected false") + } +} + +func TestLessThan(t *testing.T) { + ok, err := LessThan("500", "1000") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected true") + } + + ok, err = LessThan("1000", "1000") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected false for equal") + } +} + +func TestFormatAsEth(t *testing.T) { + got, err := FormatAsEth("1000000000000000000", 4) + if err != nil { + t.Fatal(err) + } + if got != "1.0000 ETH" { + t.Fatalf("got %q, want %q", got, "1.0000 ETH") + } + + got, err = FormatAsEth("500000000000000000", 2) + if err != nil { + t.Fatal(err) + } + if got != "0.50 ETH" { + t.Fatalf("got %q, want %q", got, "0.50 ETH") + } +} diff --git a/frontend/src/api/notificationConfig.jsx b/frontend/src/api/notificationConfig.jsx index 73010fb..0063918 100644 --- a/frontend/src/api/notificationConfig.jsx +++ b/frontend/src/api/notificationConfig.jsx @@ -1,5 +1,3 @@ -// API client for notification configuration - import { getAuthHeaders } from './authHeaders'; import { API_BASE } from './config'; @@ -38,7 +36,7 @@ export async function getNotificationConfig() { * Update notification configuration * @param {Object} config - Configuration to update * @param {string} [config.discord_webhook_url] - Discord webhook URL - * @param {string} [config.telegram_chat_id] - Telegram chat ID + * @param {string} [config.slack_webhook_url] - Slack webhook URL * @param {string} [config.email] - Email address * @param {boolean} [config.notification_enabled] - Enable/disable notifications * @returns {Promise} Updated config @@ -73,27 +71,46 @@ export async function updateNotificationConfig(config) { } /** - * Test a Discord webhook URL - * Sends a test message to verify the webhook works - * @param {string} webhookUrl - Discord webhook URL to test + * Test a webhook URL by sending a test message via the backend + * @param {string} type - 'discord' or 'slack' + * @param {string} webhookUrl - Webhook URL to test * @returns {Promise} True if test successful */ -export async function testDiscordWebhook(webhookUrl) { +async function testWebhook(type, webhookUrl) { try { - const payload = { - content: 'Koin Ping test notification - Your Discord webhook is configured correctly!', - }; - - const response = await fetch(webhookUrl, { + const headers = await getAuthHeaders(); + const response = await fetch(`${API_BASE}/notification-config/test`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + headers: headers, + body: JSON.stringify({ type, url: webhookUrl }) }); - return response.ok; + if (!response.ok) { + return false; + } + + const result = await response.json(); + return result.success === true; } catch (error) { - console.error('Discord webhook test failed:', error); + console.error(`${type} webhook test failed:`, error); return false; } } +/** + * Test a Discord webhook URL + * @param {string} webhookUrl - Discord webhook URL to test + * @returns {Promise} True if test successful + */ +export async function testDiscordWebhook(webhookUrl) { + return testWebhook('discord', webhookUrl); +} + +/** + * Test a Slack webhook URL + * @param {string} webhookUrl - Slack webhook URL to test + * @returns {Promise} True if test successful + */ +export async function testSlackWebhook(webhookUrl) { + return testWebhook('slack', webhookUrl); +} diff --git a/frontend/src/pages/Addresses.jsx b/frontend/src/pages/Addresses.jsx index 5b35896..fcca678 100644 --- a/frontend/src/pages/Addresses.jsx +++ b/frontend/src/pages/Addresses.jsx @@ -1,13 +1,14 @@ import { useState, useEffect } from "react"; import AddressForm from "../components/AddressForm"; -import { getAddresses, createAddress } from "../api/addresses"; +import Button from "../components/Button"; +import { getAddresses, createAddress, deleteAddress } from "../api/addresses"; export default function Addresses() { const [addresses, setAddresses] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); - // Load addresses on mount useEffect(() => { async function fetchAddresses() { try { @@ -25,19 +26,35 @@ export default function Addresses() { fetchAddresses(); }, []); - // Handle new address submission async function handleAddressSubmit(data) { try { const newAddress = await createAddress(data); - // Append new address to state setAddresses((prev) => [...prev, newAddress]); - setError(null); // Clear any previous errors + setError(null); } catch (err) { setError(err.message); console.error("Failed to create address:", err); } } + async function handleDelete(addressId) { + if (!window.confirm("Delete this address? All associated alert rules and events will also be removed.")) { + return; + } + + try { + setDeletingId(addressId); + await deleteAddress(addressId); + setAddresses((prev) => prev.filter((a) => a.id !== addressId)); + setError(null); + } catch (err) { + setError(err.message); + console.error("Failed to delete address:", err); + } finally { + setDeletingId(null); + } + } + return (

Tracked Addresses

@@ -63,16 +80,34 @@ export default function Addresses() { style={{ padding: "1rem", marginBottom: "0.5rem", - border: "1px solid #ddd", + border: "1px solid #444", borderRadius: "4px", + backgroundColor: "#2a2a2a", + display: "flex", + justifyContent: "space-between", + alignItems: "center", }} > -
- {addr.label || "Unlabeled"} -
-
- {addr.address} +
+
+ {addr.label || "Unlabeled"} +
+
+ {addr.address} +
+ ))} diff --git a/frontend/src/pages/Alerts.jsx b/frontend/src/pages/Alerts.jsx index 2444e0e..04d52d6 100644 --- a/frontend/src/pages/Alerts.jsx +++ b/frontend/src/pages/Alerts.jsx @@ -1,10 +1,14 @@ import { useState, useEffect } from "react"; import AlertForm from "../components/AlertForm"; import Button from "../components/Button"; -import Input from "../components/Input"; import { getAddresses } from "../api/addresses"; import { getAlerts, createAlert, updateAlertStatus, deleteAlert } from "../api/alerts"; -import { getNotificationConfig, updateNotificationConfig, testDiscordWebhook } from "../api/notificationConfig"; +import { + getNotificationConfig, + updateNotificationConfig, + testDiscordWebhook, + testSlackWebhook, +} from "../api/notificationConfig"; export default function Alerts() { const [addresses, setAddresses] = useState([]); @@ -12,33 +16,34 @@ export default function Alerts() { const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - - // Notification config state + const [notificationConfig, setNotificationConfig] = useState(null); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); + const [slackWebhookUrl, setSlackWebhookUrl] = useState(''); + const [emailAddress, setEmailAddress] = useState(''); const [notificationEnabled, setNotificationEnabled] = useState(true); const [notificationLoading, setNotificationLoading] = useState(false); const [notificationError, setNotificationError] = useState(null); const [notificationSuccess, setNotificationSuccess] = useState(null); - const [testingWebhook, setTestingWebhook] = useState(false); + const [testingDiscord, setTestingDiscord] = useState(false); + const [testingSlack, setTestingSlack] = useState(false); - // Load addresses and notification config on mount useEffect(() => { async function fetchData() { try { setLoading(true); - - // Fetch addresses + const addressData = await getAddresses(); setAddresses(addressData); if (addressData.length > 0) { setSelectedAddressId(addressData[0].id); } - - // Fetch notification config + const configData = await getNotificationConfig(); setNotificationConfig(configData); setDiscordWebhookUrl(configData.discord_webhook_url || ''); + setSlackWebhookUrl(configData.slack_webhook_url || ''); + setEmailAddress(configData.email || ''); setNotificationEnabled(configData.notification_enabled !== false); } catch (err) { setError(err.message); @@ -51,7 +56,6 @@ export default function Alerts() { fetchData(); }, []); - // Load alerts when address is selected useEffect(() => { if (!selectedAddressId) { setAlerts([]); @@ -62,7 +66,7 @@ export default function Alerts() { try { const data = await getAlerts(selectedAddressId); setAlerts(data); - setError(null); // Clear any previous errors + setError(null); } catch (err) { setError(err.message); console.error("Failed to fetch alerts:", err); @@ -72,47 +76,43 @@ export default function Alerts() { fetchAlerts(); }, [selectedAddressId]); - // Handle new alert submission async function handleAlertSubmit(data) { if (!selectedAddressId) return; try { const newAlert = await createAlert(selectedAddressId, data); setAlerts((prev) => [...prev, newAlert]); - setError(null); // Clear any previous errors + setError(null); } catch (err) { setError(err.message); console.error("Failed to create alert:", err); } } - // Toggle alert enabled/disabled async function handleToggleAlert(alertId, currentStatus) { try { const updated = await updateAlertStatus(alertId, !currentStatus); setAlerts((prev) => prev.map((alert) => (alert.id === alertId ? updated : alert)) ); - setError(null); // Clear any previous errors + setError(null); } catch (err) { setError(err.message); console.error("Failed to update alert:", err); } } - // Delete alert async function handleDeleteAlert(alertId) { try { await deleteAlert(alertId); setAlerts((prev) => prev.filter((alert) => alert.id !== alertId)); - setError(null); // Clear any previous errors + setError(null); } catch (err) { setError(err.message); console.error("Failed to delete alert:", err); } } - // Save notification config async function handleSaveNotificationConfig() { try { setNotificationLoading(true); @@ -121,14 +121,15 @@ export default function Alerts() { const config = { discord_webhook_url: discordWebhookUrl || null, + slack_webhook_url: slackWebhookUrl || null, + email: emailAddress || null, notification_enabled: notificationEnabled, }; const updated = await updateNotificationConfig(config); setNotificationConfig(updated); setNotificationSuccess('Notification settings saved!'); - - // Clear success message after 3 seconds + setTimeout(() => setNotificationSuccess(null), 3000); } catch (err) { setNotificationError(err.message); @@ -138,20 +139,19 @@ export default function Alerts() { } } - // Test Discord webhook - async function handleTestWebhook() { + async function handleTestDiscord() { if (!discordWebhookUrl) { setNotificationError('Please enter a Discord webhook URL first'); return; } try { - setTestingWebhook(true); + setTestingDiscord(true); setNotificationError(null); setNotificationSuccess(null); const success = await testDiscordWebhook(discordWebhookUrl); - + if (success) { setNotificationSuccess('Test notification sent! Check your Discord channel.'); setTimeout(() => setNotificationSuccess(null), 5000); @@ -161,7 +161,33 @@ export default function Alerts() { } catch (err) { setNotificationError('Test failed: ' + err.message); } finally { - setTestingWebhook(false); + setTestingDiscord(false); + } + } + + async function handleTestSlack() { + if (!slackWebhookUrl) { + setNotificationError('Please enter a Slack webhook URL first'); + return; + } + + try { + setTestingSlack(true); + setNotificationError(null); + setNotificationSuccess(null); + + const success = await testSlackWebhook(slackWebhookUrl); + + if (success) { + setNotificationSuccess('Test notification sent! Check your Slack channel.'); + setTimeout(() => setNotificationSuccess(null), 5000); + } else { + setNotificationError('Test failed. Check your webhook URL.'); + } + } catch (err) { + setNotificationError('Test failed: ' + err.message); + } finally { + setTestingSlack(false); } } @@ -183,14 +209,12 @@ export default function Alerts() {

Alert Rules & Notifications

- {/* Two-column layout */}
- + {/* LEFT COLUMN: Alert Rules */}

Alert Rules

- - {/* Address selector */} +