move over
This commit is contained in:
16
backend-go/.env.example
Normal file
16
backend-go/.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Server
|
||||
PORT=3001
|
||||
API_BASE_PATH=/v1
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
||||
|
||||
# Ethereum JSON-RPC
|
||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||
|
||||
# Polling interval (ms, minimum 1000)
|
||||
POLL_INTERVAL_MS=60000
|
||||
|
||||
# Firebase
|
||||
FIREBASE_PROJECT_ID=koin-ping
|
||||
52
backend-go/ENV_TEMPLATE.md
Normal file
52
backend-go/ENV_TEMPLATE.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Environment Variables Template
|
||||
|
||||
Copy this to `.env` in the backend directory and fill in your values.
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
API_BASE_PATH=/v1
|
||||
NODE_ENV=development
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/koin_ping
|
||||
|
||||
# Ethereum JSON-RPC Endpoint
|
||||
# Examples:
|
||||
# - Infura: https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||
# - Alchemy: https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY
|
||||
# - Local node: http://localhost:8545
|
||||
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||
|
||||
# Polling interval in milliseconds
|
||||
# Default: 60000 (1 minute)
|
||||
POLL_INTERVAL_MS=60000
|
||||
|
||||
# Firebase Configuration (for authentication)
|
||||
# Get this from Firebase Console > Project Settings > Project ID
|
||||
FIREBASE_PROJECT_ID=koin-ping
|
||||
```
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp ENV_TEMPLATE.md .env
|
||||
# Edit .env with your actual values
|
||||
```
|
||||
|
||||
## Firebase Setup
|
||||
|
||||
For Firebase Admin SDK to work, you need to set up Application Default Credentials:
|
||||
|
||||
**Option 1: Use Firebase Project ID (easiest for development)**
|
||||
- Just set `FIREBASE_PROJECT_ID` in .env
|
||||
- Firebase Admin will use Application Default Credentials
|
||||
|
||||
**Option 2: Use Service Account Key (production)**
|
||||
1. Go to Firebase Console > Project Settings > Service Accounts
|
||||
2. Click "Generate new private key"
|
||||
3. Download the JSON file
|
||||
4. Either:
|
||||
- Set `GOOGLE_APPLICATION_CREDENTIALS=/path/to/serviceAccountKey.json` in .env
|
||||
- Or keep it in backend/ and add to .gitignore
|
||||
4
backend-go/README.md
Normal file
4
backend-go/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Run Backend:
|
||||
|
||||
cd /Users/kjannette/workspace/koin_ping/backend-go
|
||||
go run ./cmd/api
|
||||
@@ -44,34 +44,36 @@ func main() {
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath // e.g. "/v1"
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("GET /api/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET /api/status", handlers.SystemStatus)
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
|
||||
|
||||
// Authenticated routes — addresses
|
||||
mux.Handle("POST /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||
mux.Handle("GET /api/addresses", middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE /api/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 /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET /api/addresses/{addressId}/alerts", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
mux.Handle("PATCH /api/alerts/{alertId}", middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
mux.Handle("DELETE /api/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 /api/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 /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
mux.Handle("PUT /api/notification-config", middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
mux.Handle("DELETE /api/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)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("Server running on port %d", cfg.Port)
|
||||
log.Printf("API base path: %s", cfg.APIBasePath)
|
||||
log.Printf("Environment: %s", cfg.NodeEnv)
|
||||
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
APIBasePath string
|
||||
DatabaseURL string
|
||||
DBHost string
|
||||
DBPort int
|
||||
@@ -24,6 +25,7 @@ type Config struct {
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("PORT", 3001),
|
||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", 5432),
|
||||
|
||||
@@ -30,6 +30,7 @@ func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Label *string `json:"label"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
@@ -30,15 +31,23 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
Type string `json:"type"`
|
||||
Threshold json.RawMessage `json:"threshold"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
log.Printf("User %s creating alert for address ID: %d", userID, addressID)
|
||||
threshold, err := parseThreshold(body.Threshold)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse threshold: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "threshold must be a valid number")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("User %s creating alert: type=%s, addressID=%d", userID, body.Type, addressID)
|
||||
|
||||
if body.Type == "" {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Alert type is required")
|
||||
@@ -57,7 +66,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
alertType := domain.AlertType(body.Type)
|
||||
if domain.IsThresholdRequired(alertType) {
|
||||
if body.Threshold == nil || *body.Threshold <= 0 {
|
||||
if threshold == nil || *threshold <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||
fmt.Sprintf("Alert type '%s' requires a positive threshold value", body.Type))
|
||||
return
|
||||
@@ -76,7 +85,7 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, body.Threshold)
|
||||
newAlert, err := h.alertRules.Create(r.Context(), addressID, alertType, threshold)
|
||||
if err != nil {
|
||||
log.Printf("Error creating alert rule: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create alert rule")
|
||||
@@ -87,6 +96,37 @@ func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, newAlert)
|
||||
}
|
||||
|
||||
func parseThreshold(raw json.RawMessage) (*float64, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check null before number -- json.Unmarshal treats null as valid for float64 (sets to 0).
|
||||
if string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var asNumber float64
|
||||
if err := json.Unmarshal(raw, &asNumber); err == nil {
|
||||
return &asNumber, nil
|
||||
}
|
||||
|
||||
var asString string
|
||||
if err := json.Unmarshal(raw, &asString); err == nil {
|
||||
asString = strings.TrimSpace(asString)
|
||||
if asString == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, parseErr := strconv.ParseFloat(asString, 64)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported threshold format")
|
||||
}
|
||||
|
||||
func (h *AlertRuleHandler) ListByAddress(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
@@ -136,6 +176,7 @@ func (h *AlertRuleHandler) UpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
214
backend-go/internal/handlers/alert_rule_test.go
Normal file
214
backend-go/internal/handlers/alert_rule_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseThreshold(t *testing.T) {
|
||||
t.Run("nil/empty raw message returns nil", func(t *testing.T) {
|
||||
val, err := parseThreshold(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Fatalf("expected nil, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice returns nil", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Fatalf("expected nil, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON null returns nil", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage("null"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Fatalf("expected nil, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("number 10 returns 10.0", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage("10"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val == nil {
|
||||
t.Fatal("expected non-nil value")
|
||||
}
|
||||
if *val != 10.0 {
|
||||
t.Fatalf("expected 10.0, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("number 0.5 returns 0.5", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage("0.5"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val == nil || *val != 0.5 {
|
||||
t.Fatalf("expected 0.5, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string '10' returns 10.0", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage(`"10"`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val == nil || *val != 10.0 {
|
||||
t.Fatalf("expected 10.0, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string '0.001' returns 0.001", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage(`"0.001"`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val == nil || math.Abs(*val-0.001) > 1e-9 {
|
||||
t.Fatalf("expected 0.001, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string with spaces ' 10 ' returns 10.0", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage(`" 10 "`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val == nil || *val != 10.0 {
|
||||
t.Fatalf("expected 10.0, got %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string returns nil", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage(`""`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Fatalf("expected nil, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only string returns nil", func(t *testing.T) {
|
||||
val, err := parseThreshold(json.RawMessage(`" "`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Fatalf("expected nil, got %v", *val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid string returns error", func(t *testing.T) {
|
||||
_, err := parseThreshold(json.RawMessage(`"abc"`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boolean returns error", func(t *testing.T) {
|
||||
_, err := parseThreshold(json.RawMessage("true"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for boolean")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("array returns error", func(t *testing.T) {
|
||||
_, err := parseThreshold(json.RawMessage("[1,2]"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for array")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeAlertBody(t *testing.T) {
|
||||
// Verifies that the struct used in Create handler can decode all
|
||||
// payload shapes the frontend might send.
|
||||
|
||||
type alertBody struct {
|
||||
Type string `json:"type"`
|
||||
Threshold json.RawMessage `json:"threshold"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "incoming_tx without threshold",
|
||||
payload: `{"type":"incoming_tx"}`,
|
||||
},
|
||||
{
|
||||
name: "outgoing_tx without threshold",
|
||||
payload: `{"type":"outgoing_tx"}`,
|
||||
},
|
||||
{
|
||||
name: "large_transfer with number threshold",
|
||||
payload: `{"type":"large_transfer","threshold":10}`,
|
||||
},
|
||||
{
|
||||
name: "large_transfer with string threshold",
|
||||
payload: `{"type":"large_transfer","threshold":"10"}`,
|
||||
},
|
||||
{
|
||||
name: "balance_below with number threshold",
|
||||
payload: `{"type":"balance_below","threshold":0.5}`,
|
||||
},
|
||||
{
|
||||
name: "threshold null",
|
||||
payload: `{"type":"incoming_tx","threshold":null}`,
|
||||
},
|
||||
{
|
||||
name: "empty object",
|
||||
payload: `{}`,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
payload: ``,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("unexpected decode error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldStructFailsWithStringThreshold(t *testing.T) {
|
||||
// Documents the original bug: *float64 cannot decode a string threshold.
|
||||
type oldAlertBody struct {
|
||||
Type string `json:"type"`
|
||||
Threshold *float64 `json:"threshold"`
|
||||
}
|
||||
|
||||
payload := `{"type":"large_transfer","threshold":"10"}`
|
||||
var body oldAlertBody
|
||||
err := json.Unmarshal([]byte(payload), &body)
|
||||
if err == nil {
|
||||
t.Fatal("expected error: old struct with *float64 should reject string threshold")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
||||
NotificationEnabled *bool `json:"notification_enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
log.Printf("Failed to decode notification config request body: %v", err)
|
||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user