fixed numerous linter issues

This commit is contained in:
KS Jannette
2026-02-28 20:17:55 -05:00
parent 230dc99dba
commit 90a338d46c
11 changed files with 267 additions and 73 deletions

View File

@@ -1,10 +1,10 @@
// Package main is the entry point for the API server.
package main package main
import ( import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kjannette/koin-ping/backend-go/internal/config" "github.com/kjannette/koin-ping/backend-go/internal/config"
@@ -15,6 +15,7 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
//nolint:funlen
func main() { func main() {
_ = godotenv.Load() // .env is optional; env vars can also be set externally _ = godotenv.Load() // .env is optional; env vars can also be set externally
@@ -27,12 +28,13 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
if err := firebase.Init(cfg.FirebaseProjectID); err != nil { if err := firebase.Init(cfg.FirebaseProjectID); err != nil {
log.Fatalf("Failed to initialize Firebase: %v", err) log.Fatalf("Failed to initialize Firebase: %v", err)
} }
defer database.Close()
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -51,23 +53,34 @@ func main() {
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus) mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
// Authenticated routes — addresses // Authenticated routes — addresses
mux.Handle("POST "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create))) mux.Handle("POST "+b+"/addresses",
mux.Handle("GET "+b+"/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List))) middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
mux.Handle("DELETE "+b+"/addresses/{addressId}", middleware.Authenticate(http.HandlerFunc(addressHandler.Remove))) 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 // Authenticated routes — alert rules
mux.Handle("POST "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create))) mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
mux.Handle("GET "+b+"/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress))) middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
mux.Handle("PATCH "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus))) mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
mux.Handle("DELETE "+b+"/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove))) 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 // 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 // Authenticated routes — notification config
mux.Handle("GET "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig))) mux.Handle("GET "+b+"/notification-config",
mux.Handle("PUT "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig))) middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
mux.Handle("DELETE "+b+"/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig))) 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) handler := corsMiddleware(mux)
@@ -76,9 +89,12 @@ func main() {
log.Printf("API base path: %s", cfg.APIBasePath) log.Printf("API base path: %s", cfg.APIBasePath)
log.Printf("Environment: %s", cfg.NodeEnv) 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) 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 { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }

View File

@@ -1,3 +1,4 @@
// Package main is the entry point for the blockchain observer poller.
package main package main
import ( import (
@@ -18,6 +19,14 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/services" "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() { func main() {
_ = godotenv.Load() _ = godotenv.Load()
@@ -34,13 +43,14 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)
} }
defer database.Close()
eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL) eth, err := ethereum.NewJsonRpcEthereum(cfg.EthRPCURL)
if err != nil { if err != nil {
log.Fatalf("Failed to create Ethereum observer: %v", err) log.Fatalf("Failed to create Ethereum observer: %v", err)
} }
defer database.Close()
addressModel := models.NewAddressModel(pool) addressModel := models.NewAddressModel(pool)
alertRuleModel := models.NewAlertRuleModel(pool) alertRuleModel := models.NewAlertRuleModel(pool)
alertEventModel := models.NewAlertEventModel(pool) alertEventModel := models.NewAlertEventModel(pool)
@@ -58,20 +68,20 @@ func main() {
go func() { go func() {
<-sigCh <-sigCh
log.Println() log.Println()
log.Println(strings.Repeat("=", 60)) log.Println(strings.Repeat("=", separatorWidth))
log.Println("Shutting down poller gracefully...") log.Println("Shutting down poller gracefully...")
log.Println(strings.Repeat("=", 60)) log.Println(strings.Repeat("=", separatorWidth))
cancel() cancel()
}() }()
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond 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("Koin Ping Observer Poller Starting")
log.Println(strings.Repeat("=", 60)) log.Println(strings.Repeat("=", separatorWidth))
log.Printf("RPC URL: %s", cfg.EthRPCURL) log.Printf("RPC URL: %s", cfg.EthRPCURL)
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/1000) log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
log.Println(strings.Repeat("=", 60)) log.Println(strings.Repeat("=", separatorWidth))
runCycle(ctx, observer, evaluator) runCycle(ctx, observer, evaluator)
@@ -82,6 +92,7 @@ func main() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Println("Poller stopped") log.Println("Poller stopped")
return return
case <-ticker.C: case <-ticker.C:
runCycle(ctx, observer, evaluator) 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() startTime := time.Now()
log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339)) log.Printf("[%s] Starting observation cycle...", time.Now().UTC().Format(time.RFC3339))
observations, err := observer.RunOnce(ctx) observations, err := observer.RunOnce(ctx)
if err != nil { if err != nil {
log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err) log.Printf("[%s] Observation cycle failed: %v", time.Now().UTC().Format(time.RFC3339), err)
return return
} }
alertsFired, err := evaluator.Evaluate(ctx, observations) alertsFired, err := evaluator.Evaluate(ctx, observations)
if err != nil { if err != nil {
log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err) log.Printf("[%s] Evaluation failed: %v", time.Now().UTC().Format(time.RFC3339), err)
return return
} }

View File

@@ -1,3 +1,4 @@
// Package config loads environment-based configuration.
package config package config
import ( import (
@@ -7,6 +8,18 @@ import (
"strings" "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 { type Config struct {
Port int Port int
APIBasePath string APIBasePath string
@@ -22,29 +35,31 @@ type Config struct {
NodeEnv string NodeEnv string
} }
// Load reads configuration from environment variables and returns a Config.
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Port: getEnvInt("PORT", 3001), Port: getEnvInt("PORT", defaultPort),
APIBasePath: getEnv("API_BASE_PATH", "/v1"), APIBasePath: getEnv("API_BASE_PATH", "/v1"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: getEnv("DB_HOST", "localhost"), DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnvInt("DB_PORT", 5432), DBPort: getEnvInt("DB_PORT", defaultDBPort),
DBUser: os.Getenv("DB_USER"), DBUser: os.Getenv("DB_USER"),
DBPassword: os.Getenv("DB_PASSWORD"), DBPassword: os.Getenv("DB_PASSWORD"),
DBName: os.Getenv("DB_NAME"), DBName: os.Getenv("DB_NAME"),
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
EthRPCURL: os.Getenv("ETH_RPC_URL"), EthRPCURL: os.Getenv("ETH_RPC_URL"),
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", 60000), PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
NodeEnv: getEnv("NODE_ENV", "development"), NodeEnv: getEnv("NODE_ENV", "development"),
} }
if cfg.PollIntervalMS < 1000 { if cfg.PollIntervalMS < minPollIntervalMS {
return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) return nil, fmt.Errorf("POLL_INTERVAL_MS must be >= 1000, got %d", cfg.PollIntervalMS) //nolint:err113
} }
return cfg, nil return cfg, nil
} }
// DSN returns the PostgreSQL data source name for the configured database.
func (c *Config) DSN() string { func (c *Config) DSN() string {
if c.DatabaseURL != "" { if c.DatabaseURL != "" {
// pgx defaults to sslmode=prefer, which fails against local Postgres. // pgx defaults to sslmode=prefer, which fails against local Postgres.
@@ -54,10 +69,13 @@ func (c *Config) DSN() string {
if strings.Contains(c.DatabaseURL, "?") { if strings.Contains(c.DatabaseURL, "?") {
sep = "&" sep = "&"
} }
return c.DatabaseURL + sep + "sslmode=disable" return c.DatabaseURL + sep + "sslmode=disable"
} }
return c.DatabaseURL return c.DatabaseURL
} }
return fmt.Sprintf( return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName, 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 != "" { if v := os.Getenv(key); v != "" {
return v return v
} }
return fallback return fallback
} }
@@ -77,5 +96,6 @@ func getEnvInt(key string, fallback int) int {
return n return n
} }
} }
return fallback return fallback
} }

View File

@@ -1,3 +1,4 @@
// Package database manages PostgreSQL connection pools.
package database package database
import ( import (
@@ -9,20 +10,34 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "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) { func Connect(dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn) cfg, err := pgxpool.ParseConfig(dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse database config: %w", err) return nil, fmt.Errorf("parse database config: %w", err)
} }
cfg.MaxConns = 20 cfg.MaxConns = maxConns
cfg.MinConns = 2 cfg.MinConns = minConns
cfg.MaxConnIdleTime = 30 * time.Second cfg.MaxConnIdleTime = maxConnIdleSeconds * time.Second
cfg.MaxConnLifetime = 5 * time.Minute 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() defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg) p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -32,18 +47,22 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
if err := p.Ping(ctx); err != nil { if err := p.Ping(ctx); err != nil {
p.Close() p.Close()
return nil, fmt.Errorf("ping database: %w", err) return nil, fmt.Errorf("ping database: %w", err)
} }
log.Println("Connected to PostgreSQL database") log.Println("Connected to PostgreSQL database")
pool = p pool = p
return p, nil return p, nil
} }
// Pool returns the global connection pool.
func Pool() *pgxpool.Pool { func Pool() *pgxpool.Pool {
return pool return pool
} }
// Close closes the global connection pool.
func Close() { func Close() {
if pool != nil { if pool != nil {
pool.Close() pool.Close()

View File

@@ -1,17 +1,21 @@
// Package domain defines core domain types shared across the application.
package domain package domain
import "time" import "time"
// Address represents a tracked Ethereum address.
type Address struct { type Address struct {
ID int `json:"id"` ID int `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"` //nolint:tagliatelle
Address string `json:"address"` Address string `json:"address"`
Label *string `json:"label"` 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 type AlertType string
// Alert type constants define the supported alert triggers.
const ( const (
AlertIncomingTx AlertType = "incoming_tx" AlertIncomingTx AlertType = "incoming_tx"
AlertOutgoingTx AlertType = "outgoing_tx" AlertOutgoingTx AlertType = "outgoing_tx"
@@ -19,6 +23,7 @@ const (
AlertBalanceBelow AlertType = "balance_below" AlertBalanceBelow AlertType = "balance_below"
) )
// ValidAlertTypes lists all alert types accepted by the API.
var ValidAlertTypes = []AlertType{ var ValidAlertTypes = []AlertType{
AlertIncomingTx, AlertIncomingTx,
AlertOutgoingTx, AlertOutgoingTx,
@@ -26,90 +31,104 @@ var ValidAlertTypes = []AlertType{
AlertBalanceBelow, AlertBalanceBelow,
} }
// ThresholdRequiredTypes lists alert types that require a threshold value.
var ThresholdRequiredTypes = []AlertType{ var ThresholdRequiredTypes = []AlertType{
AlertLargeTransfer, AlertLargeTransfer,
AlertBalanceBelow, AlertBalanceBelow,
} }
// IsValidAlertType returns true if the given string matches a known AlertType.
func IsValidAlertType(t string) bool { func IsValidAlertType(t string) bool {
for _, v := range ValidAlertTypes { for _, v := range ValidAlertTypes {
if string(v) == t { if string(v) == t {
return true return true
} }
} }
return false return false
} }
// IsThresholdRequired returns true if the given AlertType requires a threshold.
func IsThresholdRequired(t AlertType) bool { func IsThresholdRequired(t AlertType) bool {
for _, v := range ThresholdRequiredTypes { for _, v := range ThresholdRequiredTypes {
if v == t { if v == t {
return true return true
} }
} }
return false return false
} }
// AlertRule represents a user-defined alert rule for an address.
type AlertRule struct { type AlertRule struct {
ID int `json:"id"` ID int `json:"id"`
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Type AlertType `json:"type"` Type AlertType `json:"type"`
Threshold *float64 `json:"threshold"` Threshold *float64 `json:"threshold"`
Enabled bool `json:"enabled"` 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 { type AlertEvent struct {
ID int `json:"id"` ID int `json:"id"`
AlertRuleID int `json:"alert_rule_id"` AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
Message string `json:"message"` Message string `json:"message"`
AddressLabel *string `json:"address_label"` AddressLabel *string `json:"address_label"` //nolint:tagliatelle
TxHash *string `json:"tx_hash"` TxHash *string `json:"tx_hash"` //nolint:tagliatelle
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
// AddressCheckpoint tracks the last block checked for an address.
type AddressCheckpoint struct { type AddressCheckpoint struct {
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
LastCheckedBlock int `json:"last_checked_block"` LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
} }
// CheckpointDetail combines checkpoint and address info for reporting.
type CheckpointDetail struct { type CheckpointDetail struct {
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Address string `json:"address"` Address string `json:"address"`
Label *string `json:"label"` Label *string `json:"label"`
LastCheckedBlock int `json:"last_checked_block"` LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
LastCheckedAt time.Time `json:"last_checked_at"` LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
} }
// NotificationConfig holds a user's notification preferences.
type NotificationConfig struct { type NotificationConfig struct {
UserID string `json:"user_id"` UserID string `json:"user_id"` //nolint:tagliatelle
DiscordWebhookURL *string `json:"discord_webhook_url"` DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
TelegramChatID *string `json:"telegram_chat_id"` TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
TelegramBotToken *string `json:"telegram_bot_token,omitempty"` TelegramBotToken *string `json:"telegram_bot_token,omitempty"` //nolint:tagliatelle
Email *string `json:"email"` Email *string `json:"email"`
NotificationEnabled bool `json:"notification_enabled"` NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
CreatedAt *time.Time `json:"created_at,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
} }
// NormalizedTx is a blockchain transaction normalized for internal use.
type NormalizedTx struct { type NormalizedTx struct {
Hash string `json:"hash"` Hash string `json:"hash"`
From string `json:"from"` From string `json:"from"`
To *string `json:"to"` To *string `json:"to"`
Value string `json:"value"` // Wei as string for precision Value string `json:"value"` // Wei as string for precision
BlockNumber int `json:"block_number"` BlockNumber int `json:"block_number"` //nolint:tagliatelle
BlockTimestamp int64 `json:"block_timestamp"` BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
} }
// Direction indicates whether a transaction is incoming or outgoing.
type Direction string type Direction string
// Direction constants indicate the flow of a transaction relative to a watched address.
const ( const (
DirectionIncoming Direction = "incoming" DirectionIncoming Direction = "incoming"
DirectionOutgoing Direction = "outgoing" DirectionOutgoing Direction = "outgoing"
) )
// ObservedTx is a NormalizedTx enriched with address and direction context.
type ObservedTx struct { type ObservedTx struct {
NormalizedTx NormalizedTx
AddressID int `json:"address_id"` AddressID int `json:"address_id"` //nolint:tagliatelle
Direction Direction `json:"direction"` Direction Direction `json:"direction"`
} }

View File

@@ -1,3 +1,4 @@
// Package firebase provides Firebase authentication integration.
package firebase package firebase
import ( import (
@@ -10,12 +11,13 @@ import (
"google.golang.org/api/option" "google.golang.org/api/option"
) )
var ( var ( //nolint:gochecknoglobals
authClient *auth.Client authClient *auth.Client //nolint:gochecknoglobals
once sync.Once once sync.Once //nolint:gochecknoglobals
initErr error errInit error //nolint:gochecknoglobals
) )
// Init initializes the Firebase app and auth client using the given project ID.
func Init(projectID string) error { func Init(projectID string) error {
once.Do(func() { once.Do(func() {
ctx := context.Background() ctx := context.Background()
@@ -30,20 +32,23 @@ func Init(projectID string) error {
app, err = fb.NewApp(ctx, nil) app, err = fb.NewApp(ctx, nil)
} }
if err != nil { if err != nil {
initErr = fmt.Errorf("initialize firebase app: %w", err) errInit = fmt.Errorf("initialize firebase app: %w", err)
return return
} }
authClient, err = app.Auth(ctx) authClient, err = app.Auth(ctx)
if err != nil { if err != nil {
initErr = fmt.Errorf("initialize firebase auth: %w", err) errInit = fmt.Errorf("initialize firebase auth: %w", err)
return return
} }
}) })
return initErr return errInit
} }
// Auth returns the initialized Firebase auth client.
func Auth() *auth.Client { func Auth() *auth.Client {
return authClient return authClient
} }

View File

@@ -1,3 +1,4 @@
// Package handlers implements HTTP request handlers for the API.
package handlers package handlers
import ( import (
@@ -14,14 +15,17 @@ import (
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
// AddressHandler handles HTTP requests for address management.
type AddressHandler struct { type AddressHandler struct {
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAddressHandler creates a new AddressHandler.
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler { func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
return &AddressHandler{addresses: addresses} return &AddressHandler{addresses: addresses}
} }
// Create handles POST requests to add a new tracked address.
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) 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 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode address request body: %v", err) log.Printf("Failed to decode address request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
if body.Address == "" { if body.Address == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Address is required")
return return
} }
if !ethAddressRe.MatchString(body.Address) { if !ethAddressRe.MatchString(body.Address) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid Ethereum address format")
return return
} }
@@ -51,10 +58,12 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") { if strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "unique") {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "You are already tracking this address")
return return
} }
log.Printf("Error creating address: %v", err) log.Printf("Error creating address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create address")
return return
} }
@@ -62,6 +71,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, addr) 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) { func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
log.Printf("User %s listing addresses", userID) log.Printf("User %s listing addresses", userID)
@@ -70,6 +80,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error listing addresses: %v", err) log.Printf("Error listing addresses: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list addresses")
return return
} }
@@ -81,11 +92,13 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, addresses) writeJSON(w, http.StatusOK, addresses)
} }
// Remove handles DELETE requests to remove a tracked address.
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) { func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return return
} }
@@ -95,12 +108,14 @@ func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error deleting address: %v", err) log.Printf("Error deleting address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete address")
return return
} }
if !deleted { if !deleted {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }

View File

@@ -11,14 +11,17 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "github.com/kjannette/koin-ping/backend-go/internal/models"
) )
// AlertEventHandler handles HTTP requests for alert event history.
type AlertEventHandler struct { type AlertEventHandler struct {
alertEvents *models.AlertEventModel alertEvents *models.AlertEventModel
} }
// NewAlertEventHandler creates a new AlertEventHandler.
func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler { func NewAlertEventHandler(alertEvents *models.AlertEventModel) *AlertEventHandler {
return &AlertEventHandler{alertEvents: alertEvents} 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) { func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
@@ -34,6 +37,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if limit < 1 || limit > 100 { if limit < 1 || limit > 100 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Limit must be between 1 and 100")
return return
} }
@@ -41,6 +45,7 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error listing alert events: %v", err) log.Printf("Error listing alert events: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alert events")
return return
} }
@@ -67,15 +72,15 @@ func mockEvents(limit int) []domain.AlertEvent {
Timestamp: time.Now().Add(-2 * time.Hour), Timestamp: time.Now().Add(-2 * time.Hour),
}, },
{ {
ID: 2, ID: 2, //nolint:mnd
AlertRuleID: 2, AlertRuleID: 2, //nolint:mnd
Message: "Balance dropped below threshold: Current balance 8.2 ETH", Message: "Balance dropped below threshold: Current balance 8.2 ETH",
AddressLabel: &label1, AddressLabel: &label1,
Timestamp: time.Now().Add(-5 * time.Hour), Timestamp: time.Now().Add(-5 * time.Hour),
}, },
{ {
ID: 3, ID: 3, //nolint:mnd
AlertRuleID: 3, AlertRuleID: 3, //nolint:mnd
Message: "Outgoing transaction detected: 2.0 ETH sent", Message: "Outgoing transaction detected: 2.0 ETH sent",
AddressLabel: &label2, AddressLabel: &label2,
Timestamp: time.Now().Add(-24 * time.Hour), Timestamp: time.Now().Add(-24 * time.Hour),
@@ -85,5 +90,6 @@ func mockEvents(limit int) []domain.AlertEvent {
if limit < len(mocks) { if limit < len(mocks) {
return mocks[:limit] return mocks[:limit]
} }
return mocks return mocks
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -13,20 +14,27 @@ import (
"github.com/kjannette/koin-ping/backend-go/internal/models" "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 { type AlertRuleHandler struct {
alertRules *models.AlertRuleModel alertRules *models.AlertRuleModel
addresses *models.AddressModel addresses *models.AddressModel
} }
// NewAlertRuleHandler creates a new AlertRuleHandler.
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler { func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses} 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) { func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return 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 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode alert request body: %v", err) log.Printf("Failed to decode alert request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
@@ -44,6 +53,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Failed to parse threshold: %v", err) log.Printf("Failed to parse threshold: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
return return
} }
@@ -51,6 +61,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if body.Type == "" { if body.Type == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
return return
} }
@@ -60,7 +71,8 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
types[i] = string(t) types[i] = string(t)
} }
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", 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 return
} }
@@ -69,6 +81,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if threshold == nil || *threshold <= 0 { if threshold == nil || *threshold <= 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type)) fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
return return
} }
} }
@@ -77,11 +90,13 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error finding address: %v", err) log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return return
} }
if addr == nil { if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }
@@ -89,6 +104,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error creating alert rule: %v", err) log.Printf("Error creating alert rule: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
return return
} }
@@ -98,12 +114,12 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
func parseThreshold(raw json.RawMessage) (*float64, error) { func parseThreshold(raw json.RawMessage) (*float64, error) {
if len(raw) == 0 { 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). // Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
if string(raw) == "null" { if string(raw) == "null" {
return nil, nil return nil, nil //nolint:nilnil
} }
var asNumber float64 var asNumber float64
@@ -115,23 +131,26 @@ func parseThreshold(raw json.RawMessage) (*float64, error) {
if err := json.Unmarshal(raw, &asString); err == nil { if err := json.Unmarshal(raw, &asString); err == nil {
asString = strings.TrimSpace(asString) asString = strings.TrimSpace(asString)
if asString == "" { if asString == "" {
return nil, nil return nil, nil //nolint:nilnil
} }
parsed, parseErr := strconv.ParseFloat(asString, 64) parsed, parseErr := strconv.ParseFloat(asString, 64)
if parseErr != nil { if parseErr != nil {
return nil, parseErr return nil, parseErr
} }
return &parsed, nil 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) { func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
addressID, ok := parseIntParam(r.PathValue("addressId")) addressID, ok := parseIntParam(r.PathValue("addressId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
return return
} }
@@ -141,11 +160,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error finding address: %v", err) log.Printf("Error finding address: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return return
} }
if addr == nil { if addr == nil {
log.Printf("Address %d not found or not owned by user", addressID) log.Printf("Address %d not found or not owned by user", addressID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
return return
} }
@@ -153,6 +174,7 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error listing alerts: %v", err) log.Printf("Error listing alerts: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list alerts")
return return
} }
@@ -164,11 +186,13 @@ func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, alerts) 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) { func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return 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 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Printf("Failed to decode update request body: %v", err) log.Printf("Failed to decode update request body: %v", err)
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
return return
} }
@@ -185,6 +210,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if body.Enabled == nil { if body.Enabled == nil {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "enabled must be a boolean value")
return return
} }
@@ -192,11 +218,13 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error finding alert: %v", err) log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return return
} }
if alert == nil { if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID) log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return return
} }
@@ -204,6 +232,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
log.Printf("Error updating alert: %v", err) log.Printf("Error updating alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update alert")
return return
} }
@@ -211,11 +240,13 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
// Remove handles DELETE requests to remove an alert rule.
func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) { func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) userID := middleware.GetUserID(r.Context())
alertID, ok := parseIntParam(r.PathValue("alertId")) alertID, ok := parseIntParam(r.PathValue("alertId"))
if !ok { if !ok {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID") writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid alert ID")
return return
} }
@@ -225,17 +256,20 @@ func (h *AlertRuleHandler) Remove(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error finding alert: %v", err) log.Printf("Error finding alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return return
} }
if alert == nil { if alert == nil {
log.Printf("Alert %d not found or not owned by user", alertID) log.Printf("Alert %d not found or not owned by user", alertID)
writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found") writeError(w, http.StatusNotFound, "NOT_FOUND", "Alert rule not found")
return return
} }
if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil { if _, err := h.alertRules.Remove(r.Context(), alertID); err != nil {
log.Printf("Error deleting alert: %v", err) log.Printf("Error deleting alert: %v", err)
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert") writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete alert")
return return
} }

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // parseThreshold is unexported; internal test package required
package handlers package handlers
import ( import (
@@ -6,8 +7,13 @@ import (
"testing" "testing"
) )
//nolint:gocognit
func TestParseThreshold(t *testing.T) { func TestParseThreshold(t *testing.T) {
t.Parallel()
t.Run("nil/empty raw message returns nil", func(t *testing.T) { t.Run("nil/empty raw message returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(nil) val, err := parseThreshold(nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("empty slice returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage{}) val, err := parseThreshold(json.RawMessage{})
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("JSON null returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("null")) val, err := parseThreshold(json.RawMessage("null"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("number 10 returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("10")) val, err := parseThreshold(json.RawMessage("10"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("number 0.5 returns 0.5", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage("0.5")) val, err := parseThreshold(json.RawMessage("0.5"))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("string '10' returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`"10"`)) val, err := parseThreshold(json.RawMessage(`"10"`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("string '0.001' returns 0.001", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`"0.001"`)) val, err := parseThreshold(json.RawMessage(`"0.001"`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`" 10 "`)) val, err := parseThreshold(json.RawMessage(`" 10 "`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("empty string returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`""`)) val, err := parseThreshold(json.RawMessage(`""`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("whitespace-only string returns nil", func(t *testing.T) {
t.Parallel()
val, err := parseThreshold(json.RawMessage(`" "`)) val, err := parseThreshold(json.RawMessage(`" "`))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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.Run("invalid string returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage(`"abc"`)) _, err := parseThreshold(json.RawMessage(`"abc"`))
if err == nil { if err == nil {
t.Fatal("expected error for non-numeric string") 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.Run("boolean returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage("true")) _, err := parseThreshold(json.RawMessage("true"))
if err == nil { if err == nil {
t.Fatal("expected error for boolean") 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.Run("array returns error", func(t *testing.T) {
t.Parallel()
_, err := parseThreshold(json.RawMessage("[1,2]")) _, err := parseThreshold(json.RawMessage("[1,2]"))
if err == nil { if err == nil {
t.Fatal("expected error for array") t.Fatal("expected error for array")
@@ -132,7 +162,10 @@ func TestParseThreshold(t *testing.T) {
}) })
} }
//nolint:funlen
func TestDecodeAlertBody(t *testing.T) { func TestDecodeAlertBody(t *testing.T) {
t.Parallel()
// Verifies that the struct used in Create handler can decode all // Verifies that the struct used in Create handler can decode all
// payload shapes the frontend might send. // payload shapes the frontend might send.
@@ -183,12 +216,15 @@ func TestDecodeAlertBody(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body alertBody var body alertBody
err := json.Unmarshal([]byte(tt.payload), &body) err := json.Unmarshal([]byte(tt.payload), &body)
if tt.wantErr { if tt.wantErr {
if err == nil { if err == nil {
t.Fatal("expected decode error") t.Fatal("expected decode error")
} }
return return
} }
if err != nil { if err != nil {
@@ -199,6 +235,8 @@ func TestDecodeAlertBody(t *testing.T) {
} }
func TestOldStructFailsWithStringThreshold(t *testing.T) { func TestOldStructFailsWithStringThreshold(t *testing.T) {
t.Parallel()
// Documents the original bug: *float64 cannot decode a string threshold. // Documents the original bug: *float64 cannot decode a string threshold.
type oldAlertBody struct { type oldAlertBody struct {
Type string `json:"type"` Type string `json:"type"`

4
frontend/prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"proseWrap": "always"
}