Compare commits
1 Commits
add-anlyti
...
more-messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c99d31feff |
@@ -13,10 +13,11 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"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/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = godotenv.Load() // .env is optional; env vars can also be set externally
|
_ = godotenv.Load()
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,41 +34,47 @@ func main() {
|
|||||||
log.Fatalf("Failed to initialize Firebase: %v", err)
|
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)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
checkpointModel := models.NewCheckpointModel(pool)
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
||||||
|
statusHandler := handlers.NewStatusHandler(eth, checkpointModel)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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+"/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("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
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("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("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("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||||
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
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)))
|
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("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||||
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
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("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)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
"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/database"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"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/protocols/ethereum"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
)
|
)
|
||||||
@@ -47,8 +48,16 @@ func main() {
|
|||||||
checkpointModel := models.NewCheckpointModel(pool)
|
checkpointModel := models.NewCheckpointModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(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)
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,8 +53,7 @@ CREATE TABLE address_checkpoints (
|
|||||||
CREATE TABLE user_notification_configs (
|
CREATE TABLE user_notification_configs (
|
||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id VARCHAR(128) PRIMARY KEY,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
|
|
||||||
email VARCHAR(255), -- Email for notifications (nullable)
|
email VARCHAR(255), -- Email for notifications (nullable)
|
||||||
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ type Config struct {
|
|||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
|
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort int
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPassword string
|
||||||
|
SMTPFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -36,6 +42,12 @@ func Load() (*Config, error) {
|
|||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
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 {
|
if cfg.PollIntervalMS < 1000 {
|
||||||
|
|||||||
115
backend-go/internal/config/config_test.go
Normal file
115
backend-go/internal/config/config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,12 +82,11 @@ type CheckpointDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||||
TelegramChatID *string `json:"telegram_chat_id"`
|
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||||
TelegramBotToken *string `json:"telegram_bot_token,omitempty"`
|
Email *string `json:"email"`
|
||||||
Email *string `json:"email"`
|
NotificationEnabled bool `json:"notification_enabled"`
|
||||||
NotificationEnabled bool `json:"notification_enabled"`
|
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
35
backend-go/internal/domain/types_test.go
Normal file
35
backend-go/internal/domain/types_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"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))
|
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)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"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/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"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@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
@@ -50,7 +51,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||||
TelegramChatID *string `json:"telegram_chat_id"`
|
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
NotificationEnabled *bool `json:"notification_enabled"`
|
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)
|
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 {
|
body.Email == nil && body.NotificationEnabled == nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"At least one configuration field must be provided")
|
"At least one configuration field must be provided")
|
||||||
@@ -76,6 +77,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
return
|
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) {
|
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"Invalid email address format")
|
"Invalid email address format")
|
||||||
@@ -89,7 +97,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
|
|
||||||
cfg := domain.NotificationConfig{
|
cfg := domain.NotificationConfig{
|
||||||
DiscordWebhookURL: body.DiscordWebhookURL,
|
DiscordWebhookURL: body.DiscordWebhookURL,
|
||||||
TelegramChatID: body.TelegramChatID,
|
SlackWebhookURL: body.SlackWebhookURL,
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
NotificationEnabled: enabled,
|
NotificationEnabled: enabled,
|
||||||
}
|
}
|
||||||
@@ -126,3 +134,50 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
|
|||||||
log.Println("Notification config deleted")
|
log.Println("Notification config deleted")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"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) {
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"latestBlock": 0,
|
"latestBlock": latestBlock,
|
||||||
"lag": 0,
|
"lag": lag,
|
||||||
"status": "healthy",
|
"trackedAddresses": len(checkpoints),
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"status": status,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ func NewNotificationConfigModel(pool *pgxpool.Pool) *NotificationConfigModel {
|
|||||||
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string) (*domain.NotificationConfig, error) {
|
||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
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
|
email, notification_enabled, created_at, updated_at
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE user_id = $1`,
|
WHERE user_id = $1`,
|
||||||
userID,
|
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)
|
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "no rows in result set" {
|
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
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO user_notification_configs
|
`INSERT INTO user_notification_configs
|
||||||
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled, updated_at)
|
(user_id, discord_webhook_url, slack_webhook_url, email, notification_enabled, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
||||||
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
|
slack_webhook_url = COALESCE($3, user_notification_configs.slack_webhook_url),
|
||||||
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
|
email = COALESCE($4, user_notification_configs.email),
|
||||||
email = COALESCE($5, user_notification_configs.email),
|
notification_enabled = $5,
|
||||||
notification_enabled = $6,
|
|
||||||
updated_at = NOW()
|
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`,
|
email, notification_enabled, created_at, updated_at`,
|
||||||
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
userID, cfg.DiscordWebhookURL, cfg.SlackWebhookURL,
|
||||||
cfg.Email, cfg.NotificationEnabled,
|
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)
|
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
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
|
FROM user_notification_configs
|
||||||
WHERE notification_enabled = TRUE`,
|
WHERE notification_enabled = TRUE`,
|
||||||
)
|
)
|
||||||
@@ -85,7 +84,7 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
|
|||||||
var configs []domain.NotificationConfig
|
var configs []domain.NotificationConfig
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c domain.NotificationConfig
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
c.NotificationEnabled = true
|
c.NotificationEnabled = true
|
||||||
|
|||||||
99
backend-go/internal/notifications/discord_test.go
Normal file
99
backend-go/internal/notifications/discord_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend-go/internal/notifications/email.go
Normal file
111
backend-go/internal/notifications/email.go
Normal file
@@ -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(
|
||||||
|
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
|
||||||
|
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
|
||||||
|
meta.TxHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5">
|
||||||
|
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
|
||||||
|
<div style="background:#4f46e5;padding:20px 24px">
|
||||||
|
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px">
|
||||||
|
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p>
|
||||||
|
<table style="width:100%%;border-collapse:collapse;font-size:14px">
|
||||||
|
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr>
|
||||||
|
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
|
||||||
|
%s
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center">
|
||||||
|
Sent by Koin Ping
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend-go/internal/notifications/email_test.go
Normal file
102
backend-go/internal/notifications/email_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend-go/internal/notifications/slack.go
Normal file
128
backend-go/internal/notifications/slack.go
Normal file
@@ -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 "🔔"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend-go/internal/notifications/slack_test.go
Normal file
128
backend-go/internal/notifications/slack_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend-go/internal/protocols/ethereum/jsonrpc_test.go
Normal file
96
backend-go/internal/protocols/ethereum/jsonrpc_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type EvaluatorService struct {
|
|||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
|
smtpConfig notifications.SMTPConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -26,6 +27,7 @@ func NewEvaluatorService(
|
|||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
|
smtpCfg notifications.SMTPConfig,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
eth: eth,
|
eth: eth,
|
||||||
@@ -33,6 +35,7 @@ func NewEvaluatorService(
|
|||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
notifConfigs: notifConfigs,
|
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)
|
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 {
|
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
|
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)
|
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get notification config: %v", err)
|
log.Printf("Failed to get notification config: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
|
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sent, err := notifications.SendDiscordNotification(
|
meta := notifications.AlertMetadata{
|
||||||
*notifConfig.DiscordWebhookURL,
|
TxHash: obs.Hash,
|
||||||
message,
|
AddressLabel: addressLabel,
|
||||||
notifications.AlertMetadata{
|
AlertType: string(rule.Type),
|
||||||
TxHash: obs.Hash,
|
Address: address,
|
||||||
AddressLabel: addressLabel,
|
}
|
||||||
AlertType: string(rule.Type),
|
|
||||||
Address: address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil || !sent {
|
if notifConfig.DiscordWebhookURL != nil && *notifConfig.DiscordWebhookURL != "" {
|
||||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
sent, err := notifications.SendDiscordNotification(*notifConfig.DiscordWebhookURL, message, meta)
|
||||||
} else {
|
if err != nil || !sent {
|
||||||
log.Printf("Discord notification sent to user %s", userID)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
backend-go/internal/services/observer_test.go
Normal file
113
backend-go/internal/services/observer_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
179
backend-go/internal/wei/converter_test.go
Normal file
179
backend-go/internal/wei/converter_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// API client for notification configuration
|
|
||||||
|
|
||||||
import { getAuthHeaders } from './authHeaders';
|
import { getAuthHeaders } from './authHeaders';
|
||||||
import { API_BASE } from './config';
|
import { API_BASE } from './config';
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ export async function getNotificationConfig() {
|
|||||||
* Update notification configuration
|
* Update notification configuration
|
||||||
* @param {Object} config - Configuration to update
|
* @param {Object} config - Configuration to update
|
||||||
* @param {string} [config.discord_webhook_url] - Discord webhook URL
|
* @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 {string} [config.email] - Email address
|
||||||
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
||||||
* @returns {Promise<Object>} Updated config
|
* @returns {Promise<Object>} Updated config
|
||||||
@@ -73,27 +71,46 @@ export async function updateNotificationConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a Discord webhook URL
|
* Test a webhook URL by sending a test message via the backend
|
||||||
* Sends a test message to verify the webhook works
|
* @param {string} type - 'discord' or 'slack'
|
||||||
* @param {string} webhookUrl - Discord webhook URL to test
|
* @param {string} webhookUrl - Webhook URL to test
|
||||||
* @returns {Promise<boolean>} True if test successful
|
* @returns {Promise<boolean>} True if test successful
|
||||||
*/
|
*/
|
||||||
export async function testDiscordWebhook(webhookUrl) {
|
async function testWebhook(type, webhookUrl) {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const headers = await getAuthHeaders();
|
||||||
content: 'Koin Ping test notification - Your Discord webhook is configured correctly!',
|
const response = await fetch(`${API_BASE}/notification-config/test`, {
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: headers,
|
||||||
body: JSON.stringify(payload)
|
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) {
|
} catch (error) {
|
||||||
console.error('Discord webhook test failed:', error);
|
console.error(`${type} webhook test failed:`, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a Discord webhook URL
|
||||||
|
* @param {string} webhookUrl - Discord webhook URL to test
|
||||||
|
* @returns {Promise<boolean>} 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<boolean>} True if test successful
|
||||||
|
*/
|
||||||
|
export async function testSlackWebhook(webhookUrl) {
|
||||||
|
return testWebhook('slack', webhookUrl);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AddressForm from "../components/AddressForm";
|
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() {
|
export default function Addresses() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
|
||||||
// Load addresses on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchAddresses() {
|
async function fetchAddresses() {
|
||||||
try {
|
try {
|
||||||
@@ -25,19 +26,35 @@ export default function Addresses() {
|
|||||||
fetchAddresses();
|
fetchAddresses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle new address submission
|
|
||||||
async function handleAddressSubmit(data) {
|
async function handleAddressSubmit(data) {
|
||||||
try {
|
try {
|
||||||
const newAddress = await createAddress(data);
|
const newAddress = await createAddress(data);
|
||||||
// Append new address to state
|
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
setAddresses((prev) => [...prev, newAddress]);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create address:", err);
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1>Tracked Addresses</h1>
|
<h1>Tracked Addresses</h1>
|
||||||
@@ -63,16 +80,34 @@ export default function Addresses() {
|
|||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #444",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
|
<div>
|
||||||
{addr.label || "Unlabeled"}
|
<div style={{ fontWeight: "bold", marginBottom: "0.25rem" }}>
|
||||||
</div>
|
{addr.label || "Unlabeled"}
|
||||||
<div style={{ fontFamily: "monospace", fontSize: "0.9rem" }}>
|
</div>
|
||||||
{addr.address}
|
<div style={{ fontFamily: "monospace", fontSize: "0.9rem", color: "#999" }}>
|
||||||
|
{addr.address}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDelete(addr.id)}
|
||||||
|
disabled={deletingId === addr.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor: deletingId === addr.id ? "#333" : "#dc3545",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
cursor: deletingId === addr.id ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deletingId === addr.id ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AlertForm from "../components/AlertForm";
|
import AlertForm from "../components/AlertForm";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import Input from "../components/Input";
|
|
||||||
import { getAddresses } from "../api/addresses";
|
import { getAddresses } from "../api/addresses";
|
||||||
import { getAlerts, createAlert, updateAlertStatus, deleteAlert } from "../api/alerts";
|
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() {
|
export default function Alerts() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
@@ -13,32 +17,33 @@ export default function Alerts() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Notification config state
|
|
||||||
const [notificationConfig, setNotificationConfig] = useState(null);
|
const [notificationConfig, setNotificationConfig] = useState(null);
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState('');
|
const [discordWebhookUrl, setDiscordWebhookUrl] = useState('');
|
||||||
|
const [slackWebhookUrl, setSlackWebhookUrl] = useState('');
|
||||||
|
const [emailAddress, setEmailAddress] = useState('');
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||||
const [notificationError, setNotificationError] = useState(null);
|
const [notificationError, setNotificationError] = useState(null);
|
||||||
const [notificationSuccess, setNotificationSuccess] = 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(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch addresses
|
|
||||||
const addressData = await getAddresses();
|
const addressData = await getAddresses();
|
||||||
setAddresses(addressData);
|
setAddresses(addressData);
|
||||||
if (addressData.length > 0) {
|
if (addressData.length > 0) {
|
||||||
setSelectedAddressId(addressData[0].id);
|
setSelectedAddressId(addressData[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch notification config
|
|
||||||
const configData = await getNotificationConfig();
|
const configData = await getNotificationConfig();
|
||||||
setNotificationConfig(configData);
|
setNotificationConfig(configData);
|
||||||
setDiscordWebhookUrl(configData.discord_webhook_url || '');
|
setDiscordWebhookUrl(configData.discord_webhook_url || '');
|
||||||
|
setSlackWebhookUrl(configData.slack_webhook_url || '');
|
||||||
|
setEmailAddress(configData.email || '');
|
||||||
setNotificationEnabled(configData.notification_enabled !== false);
|
setNotificationEnabled(configData.notification_enabled !== false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -51,7 +56,6 @@ export default function Alerts() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load alerts when address is selected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAddressId) {
|
if (!selectedAddressId) {
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
@@ -62,7 +66,7 @@ export default function Alerts() {
|
|||||||
try {
|
try {
|
||||||
const data = await getAlerts(selectedAddressId);
|
const data = await getAlerts(selectedAddressId);
|
||||||
setAlerts(data);
|
setAlerts(data);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch alerts:", err);
|
console.error("Failed to fetch alerts:", err);
|
||||||
@@ -72,47 +76,43 @@ export default function Alerts() {
|
|||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
}, [selectedAddressId]);
|
}, [selectedAddressId]);
|
||||||
|
|
||||||
// Handle new alert submission
|
|
||||||
async function handleAlertSubmit(data) {
|
async function handleAlertSubmit(data) {
|
||||||
if (!selectedAddressId) return;
|
if (!selectedAddressId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newAlert = await createAlert(selectedAddressId, data);
|
const newAlert = await createAlert(selectedAddressId, data);
|
||||||
setAlerts((prev) => [...prev, newAlert]);
|
setAlerts((prev) => [...prev, newAlert]);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create alert:", err);
|
console.error("Failed to create alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle alert enabled/disabled
|
|
||||||
async function handleToggleAlert(alertId, currentStatus) {
|
async function handleToggleAlert(alertId, currentStatus) {
|
||||||
try {
|
try {
|
||||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||||
setAlerts((prev) =>
|
setAlerts((prev) =>
|
||||||
prev.map((alert) => (alert.id === alertId ? updated : alert))
|
prev.map((alert) => (alert.id === alertId ? updated : alert))
|
||||||
);
|
);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to update alert:", err);
|
console.error("Failed to update alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete alert
|
|
||||||
async function handleDeleteAlert(alertId) {
|
async function handleDeleteAlert(alertId) {
|
||||||
try {
|
try {
|
||||||
await deleteAlert(alertId);
|
await deleteAlert(alertId);
|
||||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to delete alert:", err);
|
console.error("Failed to delete alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save notification config
|
|
||||||
async function handleSaveNotificationConfig() {
|
async function handleSaveNotificationConfig() {
|
||||||
try {
|
try {
|
||||||
setNotificationLoading(true);
|
setNotificationLoading(true);
|
||||||
@@ -121,6 +121,8 @@ export default function Alerts() {
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
discord_webhook_url: discordWebhookUrl || null,
|
||||||
|
slack_webhook_url: slackWebhookUrl || null,
|
||||||
|
email: emailAddress || null,
|
||||||
notification_enabled: notificationEnabled,
|
notification_enabled: notificationEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +130,6 @@ export default function Alerts() {
|
|||||||
setNotificationConfig(updated);
|
setNotificationConfig(updated);
|
||||||
setNotificationSuccess('Notification settings saved!');
|
setNotificationSuccess('Notification settings saved!');
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError(err.message);
|
setNotificationError(err.message);
|
||||||
@@ -138,15 +139,14 @@ export default function Alerts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test Discord webhook
|
async function handleTestDiscord() {
|
||||||
async function handleTestWebhook() {
|
|
||||||
if (!discordWebhookUrl) {
|
if (!discordWebhookUrl) {
|
||||||
setNotificationError('Please enter a Discord webhook URL first');
|
setNotificationError('Please enter a Discord webhook URL first');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestingWebhook(true);
|
setTestingDiscord(true);
|
||||||
setNotificationError(null);
|
setNotificationError(null);
|
||||||
setNotificationSuccess(null);
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
@@ -161,7 +161,33 @@ export default function Alerts() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError('Test failed: ' + err.message);
|
setNotificationError('Test failed: ' + err.message);
|
||||||
} finally {
|
} 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() {
|
|||||||
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1 style={{ marginBottom: "2rem" }}>Alert Rules & Notifications</h1>
|
<h1 style={{ marginBottom: "2rem" }}>Alert Rules & Notifications</h1>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}>
|
||||||
|
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
{/* LEFT COLUMN: Alert Rules */}
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
||||||
|
|
||||||
{/* Address selector */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||||
<strong>Select Address:</strong>
|
<strong>Select Address:</strong>
|
||||||
@@ -218,7 +242,6 @@ export default function Alerts() {
|
|||||||
|
|
||||||
{selectedAddress && (
|
{selectedAddress && (
|
||||||
<>
|
<>
|
||||||
{/* Current address info */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
@@ -239,13 +262,11 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert creation form */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<h3>Create New Alert</h3>
|
<h3>Create New Alert</h3>
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing alerts list */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Alert Rules</h3>
|
<h3>Active Alert Rules</h3>
|
||||||
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
||||||
@@ -338,7 +359,7 @@ export default function Alerts() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Master toggle */}
|
{/* Master toggle */}
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#1a1a1a', borderRadius: '4px', border: '1px solid #444' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -359,7 +380,7 @@ export default function Alerts() {
|
|||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||||
Discord Webhook URL
|
Webhook URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -369,7 +390,6 @@ export default function Alerts() {
|
|||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
border: '1px solid #444',
|
border: '1px solid #444',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -390,68 +410,122 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<button
|
||||||
<button
|
onClick={handleTestDiscord}
|
||||||
onClick={handleSaveNotificationConfig}
|
disabled={testingDiscord || !discordWebhookUrl}
|
||||||
disabled={notificationLoading}
|
style={{
|
||||||
style={{
|
padding: '0.5rem 1rem',
|
||||||
padding: '0.75rem 1.5rem',
|
fontSize: '0.9rem',
|
||||||
fontSize: '1rem',
|
backgroundColor: testingDiscord || !discordWebhookUrl ? '#333' : '#28a745',
|
||||||
backgroundColor: notificationLoading ? '#333' : '#0066cc',
|
color: 'white',
|
||||||
color: 'white',
|
border: 'none',
|
||||||
border: 'none',
|
borderRadius: '4px',
|
||||||
borderRadius: '4px',
|
cursor: testingDiscord || !discordWebhookUrl ? 'not-allowed' : 'pointer'
|
||||||
cursor: notificationLoading ? 'not-allowed' : 'pointer'
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{testingDiscord ? 'Testing...' : 'Test Webhook'}
|
||||||
{notificationLoading ? 'Saving...' : 'Save Settings'}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleTestWebhook}
|
|
||||||
disabled={testingWebhook || !discordWebhookUrl}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
fontSize: '1rem',
|
|
||||||
backgroundColor: testingWebhook || !discordWebhookUrl ? '#333' : '#28a745',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: testingWebhook || !discordWebhookUrl ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{testingWebhook ? 'Testing...' : 'Test Webhook'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram Section (Coming Soon) */}
|
{/* Slack Section */}
|
||||||
<div style={{ marginBottom: '2rem', opacity: 0.5 }}>
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
<h3 style={{ marginBottom: '1rem' }}>Telegram</h3>
|
<h3 style={{ marginBottom: '1rem' }}>Slack</h3>
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
backgroundColor: '#333',
|
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||||
borderRadius: '4px',
|
Incoming Webhook URL
|
||||||
color: '#999',
|
</label>
|
||||||
textAlign: 'center'
|
<input
|
||||||
}}>
|
type="text"
|
||||||
Coming Soon
|
value={slackWebhookUrl}
|
||||||
|
onChange={(e) => setSlackWebhookUrl(e.target.value)}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}>
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#0066cc' }}
|
||||||
|
>
|
||||||
|
How to set up Slack Incoming Webhooks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTestSlack}
|
||||||
|
disabled={testingSlack || !slackWebhookUrl}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
backgroundColor: testingSlack || !slackWebhookUrl ? '#333' : '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: testingSlack || !slackWebhookUrl ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testingSlack ? 'Testing...' : 'Test Webhook'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Section (Coming Soon) */}
|
{/* Email Section */}
|
||||||
<div style={{ opacity: 0.5 }}>
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
<h3 style={{ marginBottom: '1rem' }}>Email</h3>
|
<h3 style={{ marginBottom: '1rem' }}>Email</h3>
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
backgroundColor: '#333',
|
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||||
borderRadius: '4px',
|
Email Address
|
||||||
color: '#999',
|
</label>
|
||||||
textAlign: 'center'
|
<input
|
||||||
}}>
|
type="email"
|
||||||
Coming Soon
|
value={emailAddress}
|
||||||
|
onChange={(e) => setEmailAddress(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#999', marginTop: '0.5rem' }}>
|
||||||
|
Requires SMTP to be configured on the server
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveNotificationConfig}
|
||||||
|
disabled={notificationLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
backgroundColor: notificationLoading ? '#333' : '#0066cc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: notificationLoading ? 'not-allowed' : 'pointer',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notificationLoading ? 'Saving...' : 'Save All Notification Settings'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -459,7 +533,6 @@ export default function Alerts() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format alert type for display
|
|
||||||
function formatAlertType(type) {
|
function formatAlertType(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
incoming_tx: "Incoming transaction",
|
incoming_tx: "Incoming transaction",
|
||||||
|
|||||||
Reference in New Issue
Block a user