diff --git a/backend-go/cmd/api/main.go b/backend-go/cmd/api/main.go index cef0028..d9a553b 100644 --- a/backend-go/cmd/api/main.go +++ b/backend-go/cmd/api/main.go @@ -1,10 +1,10 @@ +// Package main is the entry point for the API server. package main import ( "fmt" "log" "net/http" - "os" "github.com/joho/godotenv" "github.com/kjannette/koin-ping/backend-go/internal/config" @@ -15,6 +15,7 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/models" ) +//nolint:funlen func main() { _ = godotenv.Load() // .env is optional; env vars can also be set externally @@ -27,12 +28,13 @@ func main() { if err != nil { log.Fatalf("Failed to connect to database: %v", err) } - defer database.Close() if err := firebase.Init(cfg.FirebaseProjectID); err != nil { log.Fatalf("Failed to initialize Firebase: %v", err) } + defer database.Close() + addressModel := models.NewAddressModel(pool) alertRuleModel := models.NewAlertRuleModel(pool) alertEventModel := models.NewAlertEventModel(pool) @@ -51,23 +53,34 @@ func main() { mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus) // Authenticated routes — addresses - mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) - mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) - mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) + mux.Handle("POST "+b+"/addresses", + middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) + mux.Handle("GET "+b+"/addresses", + middleware.Authenticate(http.HandlerFunc(addressHandler.List))) + mux.Handle("DELETE "+b+"/addresses/{addressId}", + middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) // Authenticated routes — alert rules - mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) - mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) - mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) - mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) + mux.Handle("POST "+b+"/addresses/{addressId}/alerts", + middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) + mux.Handle("GET "+b+"/addresses/{addressId}/alerts", + middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) + mux.Handle("PATCH "+b+"/alerts/{alertId}", + middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) + mux.Handle("DELETE "+b+"/alerts/{alertId}", + middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) // Authenticated routes — alert events - mux.Handle("GET "+b+"/alert-events", middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) + mux.Handle("GET "+b+"/alert-events", + middleware.Authenticate(http.HandlerFunc(alertEventHandler.List))) // Authenticated routes — notification config - mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) - mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) - mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) + mux.Handle("GET "+b+"/notification-config", + middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) + mux.Handle("PUT "+b+"/notification-config", + middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) + mux.Handle("DELETE "+b+"/notification-config", + middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) handler := corsMiddleware(mux) @@ -76,9 +89,12 @@ func main() { log.Printf("API base path: %s", cfg.APIBasePath) log.Printf("Environment: %s", cfg.NodeEnv) - if err := http.ListenAndServe(addr, handler); err != nil { + server := &http.Server{ + Addr: addr, + Handler: handler, + } + if err := server.ListenAndServe(); err != nil { log.Fatalf("Server failed: %v", err) - os.Exit(1) } } @@ -90,6 +106,7 @@ func corsMiddleware(next http.Handler) http.Handler { if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) + return } diff --git a/backend-go/cmd/poller/main.go b/backend-go/cmd/poller/main.go index 9c941b8..daffb3f 100644 --- a/backend-go/cmd/poller/main.go +++ b/backend-go/cmd/poller/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the blockchain observer poller. package main import ( @@ -18,6 +19,14 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/services" ) +const ( + // separatorWidth is the number of characters in log separator lines. + separatorWidth = 60 + // msPerSecond converts milliseconds to seconds. + msPerSecond = 1000 +) + +//nolint:funlen func main() { _ = godotenv.Load() @@ -34,13 +43,14 @@ func main() { if err != nil { log.Fatalf("Failed to connect to database: %v", err) } - defer database.Close() eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL) if err != nil { log.Fatalf("Failed to create Ethereum observer: %v", err) } + defer database.Close() + addressModel := models.NewAddressModel(pool) alertRuleModel := models.NewAlertRuleModel(pool) alertEventModel := models.NewAlertEventModel(pool) @@ -58,20 +68,20 @@ func main() { go func() { <-sigCh log.Println() - log.Println(strings.Repeat("=", 60)) + log.Println(strings.Repeat("=", separatorWidth)) log.Println("Shutting down poller gracefully...") - log.Println(strings.Repeat("=", 60)) + log.Println(strings.Repeat("=", separatorWidth)) cancel() }() interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond - log.Println(strings.Repeat("=", 60)) + log.Println(strings.Repeat("=", separatorWidth)) log.Println("Koin Ping Observer Poller Starting") - log.Println(strings.Repeat("=", 60)) + log.Println(strings.Repeat("=", separatorWidth)) log.Printf("RPC URL: %s", cfg.EthRPCURL) - log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/1000) - log.Println(strings.Repeat("=", 60)) + log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond) + log.Println(strings.Repeat("=", separatorWidth)) runCycle(ctx, observer, evaluator) @@ -82,6 +92,7 @@ func main() { select { case <-ctx.Done(): log.Println("Poller stopped") + return case <-ticker.C: runCycle(ctx, observer, evaluator) @@ -89,19 +100,25 @@ func main() { } } -func runCycle(ctx context.Context, observer *services.ObserverService, evaluator *services.EvaluatorService) { +func runCycle( + ctx context.Context, + observer *services.ObserverService, + evaluator *services.EvaluatorService, +) { startTime := time.Now() log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339)) observations, err := observer.RunOnce(ctx) if err != nil { log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err) + return } alertsFired, err := evaluator.Evaluate(ctx, observations) if err != nil { log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err) + return } diff --git a/backend-go/internal/config/config.go b/backend-go/internal/config/config.go index 2ee27f6..119c755 100644 --- a/backend-go/internal/config/config.go +++ b/backend-go/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config loads environment-based configuration. package config import ( @@ -7,6 +8,18 @@ import ( "strings" ) +const ( + // defaultPort is the default HTTP server port. + defaultPort = 3001 + // defaultDBPort is the default PostgreSQL port. + defaultDBPort = 5432 + // defaultPollIntervalMS is the default poller interval in milliseconds. + defaultPollIntervalMS = 60000 + // minPollIntervalMS is the minimum allowed poller interval. + minPollIntervalMS = 1000 +) + +// Config holds application configuration. type Config struct { Port int APIBasePath string @@ -22,29 +35,31 @@ type Config struct { NodeEnv string } +// Load reads configuration from environment variables and returns a Config. func Load() (*Config, error) { cfg := &Config{ - Port: getEnvInt("PORT", 3001), + Port: getEnvInt("PORT", defaultPort), APIBasePath: getEnv("API_BASE_PATH", "/v1"), DatabaseURL: os.Getenv("DATABASE_URL"), DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnvInt("DB_PORT", 5432), + DBPort: getEnvInt("DB_PORT", defaultDBPort), DBUser: os.Getenv("DB_USER"), DBPassword: os.Getenv("DB_PASSWORD"), DBName: os.Getenv("DB_NAME"), FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), EthRPCURL: os.Getenv("ETH_RPC_URL"), - PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), + PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS), NodeEnv: getEnv("NODE_ENV", "development"), } - if cfg.PollIntervalMS < 1000 { - return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) + if cfg.PollIntervalMS < minPollIntervalMS { + return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) //nolint:err113 } return cfg, nil } +// DSN returns the PostgreSQL data source name for the configured database. func (c *Config) DSN() string { if c.DatabaseURL != "" { // pgx defaults to sslmode=prefer, which fails against local Postgres. @@ -54,10 +69,13 @@ func (c *Config) DSN() string { if strings.Contains(c.DatabaseURL, "?") { sep = "&" } + return c.DatabaseURL + sep + "sslmode=disable" } + return c.DatabaseURL } + return fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName, @@ -68,6 +86,7 @@ func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } + return fallback } @@ -77,5 +96,6 @@ func getEnvInt(key string, fallback int) int { return n } } + return fallback } diff --git a/backend-go/internal/database/postgres.go b/backend-go/internal/database/postgres.go index 7ba1cef..d9832a2 100644 --- a/backend-go/internal/database/postgres.go +++ b/backend-go/internal/database/postgres.go @@ -1,3 +1,4 @@ +// Package database manages PostgreSQL connection pools. package database import ( @@ -9,20 +10,34 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -var pool *pgxpool.Pool +const ( + // maxConnIdleSeconds is the maximum idle time for a connection. + maxConnIdleSeconds = 30 + // maxConnLifetimeMinutes is the maximum lifetime for a connection. + maxConnLifetimeMinutes = 5 + // connectTimeoutSeconds is the timeout for initial connection. + connectTimeoutSeconds = 10 + // maxConns is the maximum number of connections in the pool. + maxConns = 20 + // minConns is the minimum number of connections in the pool. + minConns = 2 +) +var pool *pgxpool.Pool //nolint:gochecknoglobals + +// Connect establishes a PostgreSQL connection pool using the given DSN. func Connect(dsn string) (*pgxpool.Pool, error) { cfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("parse database config: %w", err) } - cfg.MaxConns = 20 - cfg.MinConns = 2 - cfg.MaxConnIdleTime = 30 * time.Second - cfg.MaxConnLifetime = 5 * time.Minute + cfg.MaxConns = maxConns + cfg.MinConns = minConns + cfg.MaxConnIdleTime = maxConnIdleSeconds * time.Second + cfg.MaxConnLifetime = maxConnLifetimeMinutes * time.Minute - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), connectTimeoutSeconds*time.Second) defer cancel() p, err := pgxpool.NewWithConfig(ctx, cfg) @@ -32,18 +47,22 @@ func Connect(dsn string) (*pgxpool.Pool, error) { if err := p.Ping(ctx); err != nil { p.Close() + return nil, fmt.Errorf("ping database: %w", err) } log.Println("Connected to PostgreSQL database") pool = p + return p, nil } +// Pool returns the global connection pool. func Pool() *pgxpool.Pool { return pool } +// Close closes the global connection pool. func Close() { if pool != nil { pool.Close() diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go index 8efd13e..f98816e 100644 --- a/backend-go/internal/domain/types.go +++ b/backend-go/internal/domain/types.go @@ -1,17 +1,21 @@ +// Package domain defines core domain types shared across the application. package domain import "time" +// Address represents a tracked Ethereum address. type Address struct { ID int `json:"id"` - UserID string `json:"user_id"` + UserID string `json:"user_id"` //nolint:tagliatelle Address string `json:"address"` Label *string `json:"label"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle } +// AlertType identifies the kind of alert rule. type AlertType string +// Alert type constants define the supported alert triggers. const ( AlertIncomingTx AlertType = "incoming_tx" AlertOutgoingTx AlertType = "outgoing_tx" @@ -19,6 +23,7 @@ const ( AlertBalanceBelow AlertType = "balance_below" ) +// ValidAlertTypes lists all alert types accepted by the API. var ValidAlertTypes = []AlertType{ AlertIncomingTx, AlertOutgoingTx, @@ -26,90 +31,104 @@ var ValidAlertTypes = []AlertType{ AlertBalanceBelow, } +// ThresholdRequiredTypes lists alert types that require a threshold value. var ThresholdRequiredTypes = []AlertType{ AlertLargeTransfer, AlertBalanceBelow, } +// IsValidAlertType returns true if the given string matches a known AlertType. func IsValidAlertType(t string) bool { for _, v := range ValidAlertTypes { if string(v) == t { return true } } + return false } +// IsThresholdRequired returns true if the given AlertType requires a threshold. func IsThresholdRequired(t AlertType) bool { for _, v := range ThresholdRequiredTypes { if v == t { return true } } + return false } +// AlertRule represents a user-defined alert rule for an address. type AlertRule struct { ID int `json:"id"` - AddressID int `json:"address_id"` + AddressID int `json:"address_id"` //nolint:tagliatelle Type AlertType `json:"type"` Threshold *float64 `json:"threshold"` Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle } +// AlertEvent represents a fired alert event stored for history. type AlertEvent struct { ID int `json:"id"` - AlertRuleID int `json:"alert_rule_id"` + AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle Message string `json:"message"` - AddressLabel *string `json:"address_label"` - TxHash *string `json:"tx_hash"` + AddressLabel *string `json:"address_label"` //nolint:tagliatelle + TxHash *string `json:"tx_hash"` //nolint:tagliatelle Timestamp time.Time `json:"timestamp"` } +// AddressCheckpoint tracks the last block checked for an address. type AddressCheckpoint struct { - AddressID int `json:"address_id"` - LastCheckedBlock int `json:"last_checked_block"` - LastCheckedAt time.Time `json:"last_checked_at"` + AddressID int `json:"address_id"` //nolint:tagliatelle + LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle + LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle } +// CheckpointDetail combines checkpoint and address info for reporting. type CheckpointDetail struct { - AddressID int `json:"address_id"` + AddressID int `json:"address_id"` //nolint:tagliatelle Address string `json:"address"` Label *string `json:"label"` - LastCheckedBlock int `json:"last_checked_block"` - LastCheckedAt time.Time `json:"last_checked_at"` + LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle + LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle } +// NotificationConfig holds a user's notification preferences. type NotificationConfig struct { - UserID string `json:"user_id"` - DiscordWebhookURL *string `json:"discord_webhook_url"` - TelegramChatID *string `json:"telegram_chat_id"` - TelegramBotToken *string `json:"telegram_bot_token,omitempty"` + UserID string `json:"user_id"` //nolint:tagliatelle + DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle + TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle + TelegramBotToken *string `json:"telegram_bot_token,omitempty"` //nolint:tagliatelle Email *string `json:"email"` - NotificationEnabled bool `json:"notification_enabled"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle + CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle + UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle } +// NormalizedTx is a blockchain transaction normalized for internal use. type NormalizedTx struct { Hash string `json:"hash"` From string `json:"from"` To *string `json:"to"` Value string `json:"value"` // Wei as string for precision - BlockNumber int `json:"block_number"` - BlockTimestamp int64 `json:"block_timestamp"` + BlockNumber int `json:"block_number"` //nolint:tagliatelle + BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle } +// Direction indicates whether a transaction is incoming or outgoing. type Direction string +// Direction constants indicate the flow of a transaction relative to a watched address. const ( DirectionIncoming Direction = "incoming" DirectionOutgoing Direction = "outgoing" ) +// ObservedTx is a NormalizedTx enriched with address and direction context. type ObservedTx struct { NormalizedTx - AddressID int `json:"address_id"` + AddressID int `json:"address_id"` //nolint:tagliatelle Direction Direction `json:"direction"` } diff --git a/backend-go/internal/firebase/admin.go b/backend-go/internal/firebase/admin.go index 4903ba8..8997769 100644 --- a/backend-go/internal/firebase/admin.go +++ b/backend-go/internal/firebase/admin.go @@ -1,3 +1,4 @@ +// Package firebase provides Firebase authentication integration. package firebase import ( @@ -10,12 +11,13 @@ import ( "google.golang.org/api/option" ) -var ( - authClient *auth.Client - once sync.Once - initErr error +var ( //nolint:gochecknoglobals + authClient *auth.Client //nolint:gochecknoglobals + once sync.Once //nolint:gochecknoglobals + errInit error //nolint:gochecknoglobals ) +// Init initializes the Firebase app and auth client using the given project ID. func Init(projectID string) error { once.Do(func() { ctx := context.Background() @@ -30,20 +32,23 @@ func Init(projectID string) error { app, err = fb.NewApp(ctx, nil) } if err != nil { - initErr = fmt.Errorf("initialize firebase app: %w", err) + errInit = fmt.Errorf("initialize firebase app: %w", err) + return } authClient, err = app.Auth(ctx) if err != nil { - initErr = fmt.Errorf("initialize firebase auth: %w", err) + errInit = fmt.Errorf("initialize firebase auth: %w", err) + return } }) - return initErr + return errInit } +// Auth returns the initialized Firebase auth client. func Auth() *auth.Client { return authClient } diff --git a/backend-go/internal/handlers/address.go b/backend-go/internal/handlers/address.go index 07c8b91..f42946c 100644 --- a/backend-go/internal/handlers/address.go +++ b/backend-go/internal/handlers/address.go @@ -1,3 +1,4 @@ +// Package handlers implements HTTP request handlers for the API. package handlers import ( @@ -14,14 +15,17 @@ import ( var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) +// AddressHandler handles HTTP requests for address management. type AddressHandler struct { addresses *models.AddressModel } +// NewAddressHandler creates a new AddressHandler. func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { return &AddressHandler{addresses: addresses} } +// Create handles POST requests to add a new tracked address. func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) @@ -32,16 +36,19 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode address request body: %v", err) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return } if body.Address == "" { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required") + return } if !ethAddressRe.MatchString(body.Address) { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format") + return } @@ -51,10 +58,12 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address") + return } log.Printf("Error creating address: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address") + return } @@ -62,6 +71,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, addr) } +// List handles GET requests to list all tracked addresses for the current user. func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) log.Printf("User %s listing addresses", userID) @@ -70,6 +80,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error listing addresses: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses") + return } @@ -81,11 +92,13 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, addresses) } +// Remove handles DELETE requests to remove a tracked address. func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) addressID, ok := parseIntParam(r.PathValue("addressId")) if !ok { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return } @@ -95,12 +108,14 @@ func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error deleting address: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address") + return } if !deleted { log.Printf("Address %d not found or not owned by user", addressID) writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return } diff --git a/backend-go/internal/handlers/alert_event.go b/backend-go/internal/handlers/alert_event.go index 598e923..27aef80 100644 --- a/backend-go/internal/handlers/alert_event.go +++ b/backend-go/internal/handlers/alert_event.go @@ -11,14 +11,17 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/models" ) +// AlertEventHandler handles HTTP requests for alert event history. type AlertEventHandler struct { alertEvents *models.AlertEventModel } +// NewAlertEventHandler creates a new AlertEventHandler. func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler { return &AlertEventHandler{alertEvents: alertEvents} } +// List handles GET requests to list recent alert events for the current user. func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) @@ -34,6 +37,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) { if limit < 1 || limit > 100 { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100") + return } @@ -41,6 +45,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error listing alert events: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events") + return } @@ -67,15 +72,15 @@ func mockEvents(limit int) []domain.AlertEvent { Timestamp: time.Now().Add(-2 * time.Hour), }, { - ID: 2, - AlertRuleID: 2, + ID: 2, //nolint:mnd + AlertRuleID: 2, //nolint:mnd Message: "Balance dropped below threshold: Current balance 8.2 ETH", AddressLabel: &label1, Timestamp: time.Now().Add(-5 * time.Hour), }, { - ID: 3, - AlertRuleID: 3, + ID: 3, //nolint:mnd + AlertRuleID: 3, //nolint:mnd Message: "Outgoing transaction detected: 2.0 ETH sent", AddressLabel: &label2, Timestamp: time.Now().Add(-24 * time.Hour), @@ -85,5 +90,6 @@ func mockEvents(limit int) []domain.AlertEvent { if limit < len(mocks) { return mocks[:limit] } + return mocks } diff --git a/backend-go/internal/handlers/alert_rule.go b/backend-go/internal/handlers/alert_rule.go index 1c77880..c6ebb22 100644 --- a/backend-go/internal/handlers/alert_rule.go +++ b/backend-go/internal/handlers/alert_rule.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "fmt" "log" "net/http" @@ -13,20 +14,27 @@ import ( "github.com/kjannette/koin-ping/backend-go/internal/models" ) +// errThresholdFormat is returned when the threshold JSON cannot be decoded. +var errThresholdFormat = errors.New("unsupported threshold format") + +// AlertRuleHandler handles HTTP requests for alert rule management. type AlertRuleHandler struct { alertRules *models.AlertRuleModel addresses *models.AddressModel } +// NewAlertRuleHandler creates a new AlertRuleHandler. func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} } +// Create handles POST requests to create a new alert rule for an address. func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) addressID, ok := parseIntParam(r.PathValue("addressId")) if !ok { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return } @@ -37,6 +45,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode alert request body: %v", err) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return } @@ -44,6 +53,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Failed to parse threshold: %v", err) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number") + return } @@ -51,6 +61,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if body.Type == "" { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required") + return } @@ -60,7 +71,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { types[i] = string(t) } writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", - fmt.Sprintf("Invalid alert type. Must be one of: %s", strings.Join(types, ", "))) + "Invalid alert type. Must be one of: "+strings.Join(types, ", ")) + return } @@ -69,6 +81,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if threshold == nil || *threshold <= 0 { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type)) + return } } @@ -77,11 +90,13 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error finding address: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") + return } if addr == nil { log.Printf("Address %d not found or not owned by user", addressID) writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return } @@ -89,6 +104,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error creating alert rule: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") + return } @@ -98,12 +114,12 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) { func parseThreshold(raw json.RawMessage) (*float64, error) { if len(raw) == 0 { - return nil, nil + return nil, nil //nolint:nilnil } // Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0). if string(raw) == "null" { - return nil, nil + return nil, nil //nolint:nilnil } var asNumber float64 @@ -115,23 +131,26 @@ func parseThreshold(raw json.RawMessage) (*float64, error) { if err := json.Unmarshal(raw, &asString); err == nil { asString = strings.TrimSpace(asString) if asString == "" { - return nil, nil + return nil, nil //nolint:nilnil } parsed, parseErr := strconv.ParseFloat(asString, 64) if parseErr != nil { return nil, parseErr } + return &parsed, nil } - return nil, fmt.Errorf("unsupported threshold format") + return nil, errThresholdFormat } +// ListByAddress handles GET requests to list alert rules for an address. func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) addressID, ok := parseIntParam(r.PathValue("addressId")) if !ok { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") + return } @@ -141,11 +160,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) if err != nil { log.Printf("Error finding address: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") + return } if addr == nil { log.Printf("Address %d not found or not owned by user", addressID) writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") + return } @@ -153,6 +174,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) if err != nil { log.Printf("Error listing alerts: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") + return } @@ -164,11 +186,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, alerts) } +// UpdateStatus handles PATCH requests to enable or disable an alert rule. func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) alertID, ok := parseIntParam(r.PathValue("alertId")) if !ok { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") + return } @@ -178,6 +202,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Printf("Failed to decode update request body: %v", err) writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") + return } @@ -185,6 +210,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) if body.Enabled == nil { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") + return } @@ -192,11 +218,13 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) if err != nil { log.Printf("Error finding alert: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + return } if alert == nil { log.Printf("Alert %d not found or not owned by user", alertID) writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") + return } @@ -204,6 +232,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) if err != nil { log.Printf("Error updating alert: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") + return } @@ -211,11 +240,13 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, updated) } +// Remove handles DELETE requests to remove an alert rule. func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) { userID := middleware.GetUserID(r.Context()) alertID, ok := parseIntParam(r.PathValue("alertId")) if !ok { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") + return } @@ -225,17 +256,20 @@ func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error finding alert: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") + return } if alert == nil { log.Printf("Alert %d not found or not owned by user", alertID) writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") + return } if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil { log.Printf("Error deleting alert: %v", err) writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") + return } diff --git a/backend-go/internal/handlers/alert_rule_test.go b/backend-go/internal/handlers/alert_rule_test.go index b9a2d50..ea7ac88 100644 --- a/backend-go/internal/handlers/alert_rule_test.go +++ b/backend-go/internal/handlers/alert_rule_test.go @@ -1,3 +1,4 @@ +//nolint:testpackage // parseThreshold is unexported; internal test package required package handlers import ( @@ -6,8 +7,13 @@ import ( "testing" ) +//nolint:gocognit func TestParseThreshold(t *testing.T) { + t.Parallel() + t.Run("nil/empty raw message returns nil", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -18,6 +24,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("empty slice returns nil", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -28,6 +36,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("JSON null returns nil", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage("null")) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -38,6 +48,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("number 10 returns 10.0", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage("10")) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -51,6 +63,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("number 0.5 returns 0.5", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage("0.5")) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -61,6 +75,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("string '10' returns 10.0", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage(`"10"`)) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -71,6 +87,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("string '0.001' returns 0.001", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage(`"0.001"`)) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -81,6 +99,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage(`" 10 "`)) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -91,6 +111,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("empty string returns nil", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage(`""`)) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -101,6 +123,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("whitespace-only string returns nil", func(t *testing.T) { + t.Parallel() + val, err := parseThreshold(json.RawMessage(`" "`)) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -111,6 +135,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("invalid string returns error", func(t *testing.T) { + t.Parallel() + _, err := parseThreshold(json.RawMessage(`"abc"`)) if err == nil { t.Fatal("expected error for non-numeric string") @@ -118,6 +144,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("boolean returns error", func(t *testing.T) { + t.Parallel() + _, err := parseThreshold(json.RawMessage("true")) if err == nil { t.Fatal("expected error for boolean") @@ -125,6 +153,8 @@ func TestParseThreshold(t *testing.T) { }) t.Run("array returns error", func(t *testing.T) { + t.Parallel() + _, err := parseThreshold(json.RawMessage("[1,2]")) if err == nil { t.Fatal("expected error for array") @@ -132,7 +162,10 @@ func TestParseThreshold(t *testing.T) { }) } +//nolint:funlen func TestDecodeAlertBody(t *testing.T) { + t.Parallel() + // Verifies that the struct used in Create handler can decode all // payload shapes the frontend might send. @@ -183,12 +216,15 @@ func TestDecodeAlertBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var body alertBody err := json.Unmarshal([]byte(tt.payload), &body) if tt.wantErr { if err == nil { t.Fatal("expected decode error") } + return } if err != nil { @@ -199,6 +235,8 @@ func TestDecodeAlertBody(t *testing.T) { } func TestOldStructFailsWithStringThreshold(t *testing.T) { + t.Parallel() + // Documents the original bug: *float64 cannot decode a string threshold. type oldAlertBody struct { Type string `json:"type"` diff --git a/frontend/prettierrc b/frontend/prettierrc new file mode 100644 index 0000000..b00b440 --- /dev/null +++ b/frontend/prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} \ No newline at end of file