Compare commits
32 Commits
setupTeleg
...
login-styl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068ca2f834 | ||
|
|
7f10bcb7de | ||
|
|
f91ca33752 | ||
|
|
2c86bba235 | ||
|
|
44dad43f1d | ||
|
|
2fe0e5b8e9 | ||
|
|
8f08105246 | ||
|
|
2bc06669e9 | ||
|
|
a2d1ff9d76 | ||
|
|
dcbf0756d2 | ||
|
|
239ea7db85 | ||
|
|
9ad414313b | ||
|
|
3ee1598478 | ||
|
|
91336130b9 | ||
|
|
e4e859d051 | ||
|
|
6f6a2c6d18 | ||
|
|
63eecaaf72 | ||
|
|
8a5f5db903 | ||
|
|
c2515472b2 | ||
|
|
5ad51271af | ||
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 | ||
|
|
6735000050 | ||
|
|
42a64fc043 | ||
|
|
9935817fa8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,7 +21,7 @@ node_modules/
|
||||
*.key
|
||||
|
||||
# Go build artifacts
|
||||
backend-go/bin/
|
||||
backend/bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
backend-go/bin/
|
||||
backend/bin/
|
||||
*.lock
|
||||
Prompts/
|
||||
.claude/
|
||||
|
||||
2
Makefile
2
Makefile
@@ -2,7 +2,7 @@
|
||||
test-go test-js lint-go lint-js fmt-go fmt-js fmt-check-go fmt-check-js \
|
||||
build-go build-js
|
||||
|
||||
GODIR := backend-go
|
||||
GODIR := backend
|
||||
JSDIR := frontend
|
||||
PRETTIER := $(JSDIR)/node_modules/.bin/prettier
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -5,7 +5,6 @@ A lightweight on-chain monitoring and alerting system designed to give users sit
|
||||
|
||||
Koin Ping observes on-chain activity and notifies users when predefined conditions are met. It does not execute transactions, manage wallets, or speculate on prices.
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -29,8 +28,8 @@ make hooks
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Copy and fill in environment variables
|
||||
cp backend-go/.env.example backend-go/.env
|
||||
# edit backend-go/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||
cp backend/.env.example backend/.env
|
||||
# edit backend/.env with your DATABASE_URL, FIREBASE_PROJECT_ID, ETH_RPC_URL
|
||||
|
||||
# Run checks (requires golangci-lint)
|
||||
make check
|
||||
@@ -39,23 +38,23 @@ make check
|
||||
make run
|
||||
|
||||
# Start the poller (separate terminal)
|
||||
cd backend-go && go run ./cmd/poller
|
||||
cd backend && go run ./cmd/poller
|
||||
|
||||
# Start the frontend dev server (separate terminal)
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:3001` and the frontend on
|
||||
`http://localhost:3000` by default.
|
||||
`http://localhost:3000` by default for development.
|
||||
|
||||
## Rationale
|
||||
|
||||
Crypto users who hold or actively monitor addresses need a lightweight, reliable
|
||||
way to know when on-chain activity occurs without polling block explorers
|
||||
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
|
||||
evaluates configurable alert rules (incoming transactions, outgoing
|
||||
transactions, large transfers, balance thresholds), and notifies the user
|
||||
through Discord webhooks.
|
||||
Crypto users may actively monitor addresses with webhooks integrating popular
|
||||
messaging platforms: Discord, Slack, Telegram.
|
||||
This lightweight, reliable framework makes instant awareness of on-chain
|
||||
activity trivial, without polling block explorers manually. Koin Ping watches
|
||||
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
||||
transactions, "large" transfers, balance thresholds), and sends notifications.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -64,7 +63,7 @@ frontend:
|
||||
|
||||
```
|
||||
koin_ping_0.2.0/
|
||||
├── backend-go/ # Go monorepo root
|
||||
├── backend/ # Go monorepo root
|
||||
│ ├── cmd/api/ # HTTP REST API server
|
||||
│ ├── cmd/poller/ # Blockchain polling daemon
|
||||
│ └── internal/
|
||||
@@ -132,7 +131,7 @@ To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
||||
|
||||
4. In the JSON response, find the `"chat"` object — the `"id"` field is your **Chat ID** (a numeric value).
|
||||
|
||||
> **Tip:** If the `"result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
|
||||
> **Tip:** If the `'result"` array is empty, make sure you sent a message to your bot first, then refresh the page.
|
||||
|
||||
#### 3. Save in Koin Ping
|
||||
|
||||
@@ -153,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
|
||||
## Author
|
||||
|
||||
Steven Jannette
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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
|
||||
|
||||
# Email notifications (Resend — https://resend.com)
|
||||
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
||||
@@ -1,7 +0,0 @@
|
||||
Start DB:
|
||||
|
||||
brew services start postgresql@15
|
||||
|
||||
Run Backend:
|
||||
|
||||
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
||||
@@ -1,99 +0,0 @@
|
||||
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Println("No Authorization header or invalid format")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "No authentication token provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" {
|
||||
log.Println("Empty token")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Verifying Firebase token...")
|
||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("Token verification failed: %v", err)
|
||||
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "expired") {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "TOKEN_EXPIRED",
|
||||
Message: "Authentication token has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Failed to verify authentication token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := decoded.UID
|
||||
email, _ := decoded.Claims["email"].(string)
|
||||
|
||||
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserEmail(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package notifications
|
||||
|
||||
import "context"
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
}
|
||||
|
||||
// Notifier is the interface implemented by all notification channels.
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
20
backend/README.md
Normal file
20
backend/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
Start DB:
|
||||
|
||||
brew services start postgresql@15
|
||||
|
||||
From the backend directory, you have a few options:
|
||||
|
||||
Option 1: Single command (both API + poller)
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backendmake dev-all
|
||||
|
||||
Option 2: Two separate terminals
|
||||
Terminal 1 (API server):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/api
|
||||
Terminal 2 (Poller):
|
||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend go run ./cmd/poller
|
||||
|
||||
make run — Builds and runs the API server.
|
||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||
make poller — Builds and runs the poller.
|
||||
make poller-dev — Runs the poller with auto-reload.
|
||||
make dev-all — Runs both the API and poller concurrently.
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/handlers"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -44,6 +44,7 @@ func main() {
|
||||
|
||||
defer database.Close()
|
||||
|
||||
userModel := models.NewUserModel(pool)
|
||||
addressModel := models.NewAddressModel(pool)
|
||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||
alertEventModel := models.NewAlertEventModel(pool)
|
||||
@@ -60,6 +61,15 @@ func main() {
|
||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||
stripeHandler := handlers.NewStripeHandler(userModel, cfg)
|
||||
|
||||
authenticate := middleware.Authenticate(userModel)
|
||||
requireSub := middleware.RequireSubscription(userModel)
|
||||
|
||||
// authAndSub chains authentication + subscription check for protected routes.
|
||||
authAndSub := func(h http.Handler) http.Handler {
|
||||
return authenticate(requireSub(h))
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
b := cfg.APIBasePath // e.g. "/v1"
|
||||
@@ -68,45 +78,56 @@ func main() {
|
||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||
|
||||
// Authenticated routes — addresses
|
||||
// Stripe webhook (public — called by Stripe, not authenticated)
|
||||
mux.HandleFunc("POST "+b+"/stripe/webhook", stripeHandler.HandleWebhook)
|
||||
|
||||
// Stripe routes (auth required, NO subscription required)
|
||||
mux.Handle("POST "+b+"/stripe/create-checkout-session",
|
||||
authenticate(http.HandlerFunc(stripeHandler.CreateCheckoutSession)))
|
||||
mux.Handle("GET "+b+"/stripe/subscription-status",
|
||||
authenticate(http.HandlerFunc(stripeHandler.GetSubscriptionStatus)))
|
||||
mux.Handle("POST "+b+"/stripe/verify-checkout",
|
||||
authenticate(http.HandlerFunc(stripeHandler.VerifyCheckoutSession)))
|
||||
|
||||
// Authenticated + subscribed routes — addresses
|
||||
mux.Handle("POST "+b+"/addresses",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Create)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.List)))
|
||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.Remove)))
|
||||
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||
authAndSub(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||
|
||||
// Authenticated routes for alert rules
|
||||
// Authenticated + subscribed routes — alert rules
|
||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.Create)))
|
||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.ListByAddress)))
|
||||
mux.Handle("PATCH "+b+"/alerts/{alertId}",
|
||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.UpdateStatus)))
|
||||
mux.Handle("DELETE "+b+"/alerts/{alertId}",
|
||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||
authAndSub(http.HandlerFunc(alertRuleHandler.Remove)))
|
||||
|
||||
// Authenticated routes — alert events
|
||||
// Authenticated + subscribed routes — alert events
|
||||
mux.Handle("GET "+b+"/alert-events",
|
||||
middleware.Authenticate(http.HandlerFunc(alertEventHandler.List)))
|
||||
authAndSub(http.HandlerFunc(alertEventHandler.List)))
|
||||
|
||||
// Authenticated routes — notification config
|
||||
// Authenticated + subscribed routes — notification config
|
||||
mux.Handle("GET "+b+"/notification-config",
|
||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.GetConfig)))
|
||||
mux.Handle("PUT "+b+"/notification-config",
|
||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||
mux.Handle("DELETE "+b+"/notification-config",
|
||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||
mux.Handle("POST "+b+"/notification-config/test",
|
||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||
authAndSub(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||
|
||||
// Authenticated routes — email digest
|
||||
// Authenticated + subscribed routes — email digest
|
||||
mux.Handle("POST "+b+"/email/setup",
|
||||
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||
mux.Handle("POST "+b+"/email/digest",
|
||||
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||
authAndSub(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/database"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -138,6 +138,8 @@ func runCycle(
|
||||
return
|
||||
}
|
||||
|
||||
evaluator.WaitForNotifications()
|
||||
|
||||
duration := time.Since(startTime)
|
||||
log.Printf("[%s] Cycle complete: %d observations, %d alerts fired in %s",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/kjannette/koin-ping/backend-go
|
||||
module github.com/kjannette/koin-ping/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
@@ -44,6 +44,7 @@ require (
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/resend/resend-go/v3 v3.1.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stripe/stripe-go/v82 v82.5.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
@@ -101,6 +101,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
13
backend/infra/migrations/005_create_users_table.sql
Normal file
13
backend/infra/migrations/005_create_users_table.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration 005: Create users table with UUID primary key
|
||||
-- This establishes a local user record for each Firebase-authenticated user.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid);
|
||||
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
73
backend/infra/migrations/006_migrate_user_ids_to_uuid.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
-- Migration 006: Migrate user_id columns from Firebase UID strings to user UUIDs
|
||||
--
|
||||
-- Prerequisites: migration 005 (users table) must be applied first.
|
||||
-- This migration backfills the users table from existing data, then swaps the
|
||||
-- VARCHAR user_id columns for UUID foreign keys referencing users(id).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill users table from existing Firebase UIDs in addresses
|
||||
INSERT INTO users (firebase_uid, email)
|
||||
SELECT DISTINCT user_id, ''
|
||||
FROM addresses
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||
|
||||
-- 2. Backfill from notification configs (catches users with configs but no addresses)
|
||||
INSERT INTO users (firebase_uid, email)
|
||||
SELECT DISTINCT user_id, ''
|
||||
FROM user_notification_configs
|
||||
WHERE user_id IS NOT NULL
|
||||
ON CONFLICT (firebase_uid) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Migrate addresses.user_id from VARCHAR to UUID
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE addresses ADD COLUMN user_uuid UUID;
|
||||
|
||||
UPDATE addresses a
|
||||
SET user_uuid = u.id
|
||||
FROM users u
|
||||
WHERE u.firebase_uid = a.user_id;
|
||||
|
||||
-- Drop old constraints and column
|
||||
ALTER TABLE addresses DROP CONSTRAINT IF EXISTS addresses_user_id_address_key;
|
||||
DROP INDEX IF EXISTS idx_addresses_user_id;
|
||||
ALTER TABLE addresses DROP COLUMN user_id;
|
||||
|
||||
-- Rename and constrain
|
||||
ALTER TABLE addresses RENAME COLUMN user_uuid TO user_id;
|
||||
ALTER TABLE addresses ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE addresses ADD CONSTRAINT fk_addresses_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
ALTER TABLE addresses ADD CONSTRAINT addresses_user_id_address_key
|
||||
UNIQUE (user_id, address);
|
||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Migrate user_notification_configs.user_id from VARCHAR to UUID
|
||||
-- ============================================================
|
||||
|
||||
-- Drop the PK first (it's on user_id)
|
||||
ALTER TABLE user_notification_configs DROP CONSTRAINT IF EXISTS user_notification_configs_pkey;
|
||||
DROP INDEX IF EXISTS idx_notification_configs_enabled;
|
||||
|
||||
ALTER TABLE user_notification_configs ADD COLUMN user_uuid UUID;
|
||||
|
||||
UPDATE user_notification_configs nc
|
||||
SET user_uuid = u.id
|
||||
FROM users u
|
||||
WHERE u.firebase_uid = nc.user_id;
|
||||
|
||||
ALTER TABLE user_notification_configs DROP COLUMN user_id;
|
||||
ALTER TABLE user_notification_configs RENAME COLUMN user_uuid TO user_id;
|
||||
ALTER TABLE user_notification_configs ALTER COLUMN user_id SET NOT NULL;
|
||||
ALTER TABLE user_notification_configs ADD CONSTRAINT user_notification_configs_pkey
|
||||
PRIMARY KEY (user_id);
|
||||
ALTER TABLE user_notification_configs ADD CONSTRAINT fk_notification_configs_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
CREATE INDEX idx_notification_configs_enabled
|
||||
ON user_notification_configs(notification_enabled);
|
||||
|
||||
COMMIT;
|
||||
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
8
backend/infra/migrations/007_add_stripe_to_users.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Migration 007: Add Stripe subscription fields to users table
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(50) DEFAULT 'none';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_created_at TIMESTAMP;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||
@@ -4,15 +4,28 @@ DROP TABLE IF EXISTS alert_rules CASCADE;
|
||||
DROP TABLE IF EXISTS address_checkpoints CASCADE;
|
||||
DROP TABLE IF EXISTS user_notification_configs CASCADE;
|
||||
DROP TABLE IF EXISTS addresses CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
firebase_uid VARCHAR(128) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
subscription_status VARCHAR(50) DEFAULT 'none',
|
||||
subscription_created_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE addresses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(128) NOT NULL, -- Firebase user ID (multi-user support)
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
address VARCHAR(42) NOT NULL, -- Ethereum address format (0x + 40 hex chars)
|
||||
label VARCHAR(255), -- Optional human-readable label
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
||||
-- Unique users can track the same address independently
|
||||
|
||||
UNIQUE(user_id, address)
|
||||
);
|
||||
|
||||
@@ -51,7 +64,7 @@ CREATE TABLE address_checkpoints (
|
||||
);
|
||||
|
||||
CREATE TABLE user_notification_configs (
|
||||
user_id VARCHAR(128) PRIMARY KEY,
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||
@@ -63,6 +76,8 @@ CREATE TABLE user_notification_configs (
|
||||
);
|
||||
|
||||
-- Create indexes for common queries
|
||||
CREATE INDEX idx_users_firebase_uid ON users(firebase_uid);
|
||||
CREATE INDEX idx_users_stripe_customer_id ON users(stripe_customer_id);
|
||||
CREATE INDEX idx_addresses_user_id ON addresses(user_id);
|
||||
CREATE INDEX idx_alert_rules_address_id ON alert_rules(address_id);
|
||||
CREATE INDEX idx_alert_rules_enabled ON alert_rules(enabled);
|
||||
@@ -32,6 +32,11 @@ type Config struct {
|
||||
ResendAPIKey string
|
||||
EmailFrom string
|
||||
DigestIntervalHours int
|
||||
StripeSecretKey string
|
||||
StripeWebhookSecret string
|
||||
StripePriceID string
|
||||
StripePublishableKey string
|
||||
FrontendURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and returns a Config.
|
||||
@@ -51,7 +56,12 @@ func Load() (*Config, error) {
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
StripePriceID: os.Getenv("STRIPE_PRICE_ID"),
|
||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
}
|
||||
|
||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package database manages PostgreSQL connection pools.
|
||||
package database
|
||||
|
||||
import (
|
||||
@@ -11,21 +10,21 @@ import (
|
||||
)
|
||||
|
||||
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.
|
||||
// establishPostgreSQL connection pool using the given DSN.
|
||||
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||
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()
|
||||
@@ -1,9 +1,20 @@
|
||||
// Package domain defines core domain types shared across the application.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Address represents a tracked Ethereum address.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
FirebaseUID string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"` //nolint:tagliatelle
|
||||
StripeCustomerID *string `json:"-"`
|
||||
StripeSubscriptionID *string `json:"-"`
|
||||
SubscriptionStatus string `json:"subscription_status"` //nolint:tagliatelle
|
||||
SubscriptionCreatedAt *time.Time `json:"subscription_created_at,omitempty"` //nolint:tagliatelle
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
type Address struct {
|
||||
ID int `json:"id"`
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
@@ -12,13 +23,10 @@ type Address struct {
|
||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// AlertType identifies the kind of alert rule.
|
||||
type AlertType string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (a AlertType) String() string { return string(a) }
|
||||
|
||||
// Alert type constants define the supported alert triggers.
|
||||
const (
|
||||
AlertIncomingTx AlertType = "incoming_tx"
|
||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||
@@ -26,7 +34,6 @@ const (
|
||||
AlertBalanceBelow AlertType = "balance_below"
|
||||
)
|
||||
|
||||
// ValidAlertTypes lists all alert types accepted by the API.
|
||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertIncomingTx,
|
||||
AlertOutgoingTx,
|
||||
@@ -40,7 +47,7 @@ var ThresholdRequiredTypes = []AlertType{ //nolint:gochecknoglobals
|
||||
AlertBalanceBelow,
|
||||
}
|
||||
|
||||
// IsValidAlertType returns true if the given string matches a known AlertType.
|
||||
// returns true if the given string matches a known AlertType.
|
||||
func IsValidAlertType(t string) bool {
|
||||
for _, v := range ValidAlertTypes {
|
||||
if string(v) == t {
|
||||
@@ -51,7 +58,6 @@ func IsValidAlertType(t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsThresholdRequired returns true if the given AlertType requires a threshold.
|
||||
func IsThresholdRequired(t AlertType) bool {
|
||||
for _, v := range ThresholdRequiredTypes {
|
||||
if v == t {
|
||||
@@ -62,7 +68,6 @@ func IsThresholdRequired(t AlertType) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AlertRule represents a user-defined alert rule for an address.
|
||||
type AlertRule struct {
|
||||
ID int `json:"id"`
|
||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||
@@ -72,7 +77,6 @@ type AlertRule struct {
|
||||
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"` //nolint:tagliatelle
|
||||
@@ -82,14 +86,12 @@ type AlertEvent struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AddressCheckpoint tracks the last block checked for an address.
|
||||
type AddressCheckpoint struct {
|
||||
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"` //nolint:tagliatelle
|
||||
Address string `json:"address"`
|
||||
@@ -98,7 +100,7 @@ type CheckpointDetail struct {
|
||||
LastCheckedAt time.Time `json:"last_checked_at"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
// NotificationConfig holds a user's notification preferences.
|
||||
// holds a user's notification preferences.
|
||||
type NotificationConfig struct {
|
||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||
@@ -119,21 +121,27 @@ type NormalizedTx struct {
|
||||
Value string `json:"value"` // Wei as string for precision
|
||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
|
||||
|
||||
// ERC-20 token transfer fields (nil for native ETH transfers)
|
||||
TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle
|
||||
TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle
|
||||
TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle
|
||||
TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle
|
||||
}
|
||||
|
||||
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||
return tx.TokenContract != nil
|
||||
}
|
||||
|
||||
// Direction indicates whether a transaction is incoming or outgoing.
|
||||
type Direction string
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (d Direction) String() string { return string(d) }
|
||||
|
||||
// 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"` //nolint:tagliatelle
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package firebase provides Firebase authentication integration.
|
||||
package firebase
|
||||
|
||||
import (
|
||||
@@ -17,7 +16,6 @@ var ( //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()
|
||||
@@ -48,7 +46,6 @@ func Init(projectID string) error {
|
||||
return errInit
|
||||
}
|
||||
|
||||
// Auth returns the initialized Firebase auth client.
|
||||
func Auth() *auth.Client {
|
||||
return authClient
|
||||
}
|
||||
@@ -8,24 +8,21 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
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())
|
||||
|
||||
@@ -92,7 +89,7 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, addresses)
|
||||
}
|
||||
|
||||
// UpdateLabel handles PATCH requests to update an address label.
|
||||
// handles PATCH requests to update an address label.
|
||||
func (h *AddressHandler) UpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// AlertEventHandler handles HTTP requests for alert event history.
|
||||
@@ -9,26 +9,22 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/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"))
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/services"
|
||||
)
|
||||
|
||||
type EmailDigestHandler struct {
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
)
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
// StatusHandler handles the system status endpoint.
|
||||
254
backend/internal/handlers/stripe.go
Normal file
254
backend/internal/handlers/stripe.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v82"
|
||||
checkoutsession "github.com/stripe/stripe-go/v82/checkout/session"
|
||||
"github.com/stripe/stripe-go/v82/webhook"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/config"
|
||||
"github.com/kjannette/koin-ping/backend/internal/middleware"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const webhookMaxBodyBytes = 65536
|
||||
|
||||
type StripeHandler struct {
|
||||
users *models.UserModel
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewStripeHandler(users *models.UserModel, cfg *config.Config) *StripeHandler {
|
||||
stripe.Key = cfg.StripeSecretKey
|
||||
return &StripeHandler{users: users, cfg: cfg}
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe Checkout session for the monthly subscription.
|
||||
func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(h.cfg.StripePriceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||
ClientReferenceID: stripe.String(userID),
|
||||
CustomerEmail: stripe.String(user.Email),
|
||||
}
|
||||
|
||||
if user.StripeCustomerID != nil && *user.StripeCustomerID != "" {
|
||||
params.Customer = user.StripeCustomerID
|
||||
params.CustomerEmail = nil
|
||||
}
|
||||
|
||||
s, err := checkoutsession.New(params)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Stripe checkout session: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "STRIPE_ERROR", "Failed to create checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": s.URL})
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus returns the current user's subscription state.
|
||||
func (h *StripeHandler) GetSubscriptionStatus(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
user, err := h.users.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("Failed to get user %s: %v", userID, err)
|
||||
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"subscription_status": user.SubscriptionStatus,
|
||||
"subscription_created_at": user.SubscriptionCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyCheckoutSession retrieves a completed checkout session from Stripe,
|
||||
// confirms payment, and activates the user's subscription in the database.
|
||||
// This is the primary activation path; webhooks serve as a backup.
|
||||
func (h *StripeHandler) VerifyCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
|
||||
var body struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SessionID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Missing session_id")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := checkoutsession.Get(body.SessionID, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to retrieve checkout session %s: %v", body.SessionID, err)
|
||||
writeError(w, http.StatusBadRequest, "STRIPE_ERROR", "Invalid checkout session")
|
||||
return
|
||||
}
|
||||
|
||||
if s.ClientReferenceID != userID {
|
||||
writeError(w, http.StatusForbidden, "FORBIDDEN", "Session does not belong to this user")
|
||||
return
|
||||
}
|
||||
|
||||
if s.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid {
|
||||
writeError(w, http.StatusBadRequest, "PAYMENT_INCOMPLETE", "Payment has not been completed")
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if s.Customer != nil {
|
||||
customerID = s.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if s.Subscription != nil {
|
||||
subscriptionID = s.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to save customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
||||
log.Printf("VerifyCheckout: failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout verified for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"subscription_status": "active"})
|
||||
}
|
||||
|
||||
// HandleWebhook processes incoming Stripe webhook events.
|
||||
// This endpoint must NOT require authentication (Stripe calls it directly).
|
||||
func (h *StripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("Error reading webhook body: %v", err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
event, err := webhook.ConstructEvent(payload, sig, h.cfg.StripeWebhookSecret)
|
||||
if err != nil {
|
||||
log.Printf("Webhook signature verification failed: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
h.handleCheckoutCompleted(r, event)
|
||||
case "customer.subscription.updated":
|
||||
h.handleSubscriptionUpdated(r, event)
|
||||
case "customer.subscription.deleted":
|
||||
h.handleSubscriptionDeleted(r, event)
|
||||
default:
|
||||
log.Printf("Unhandled Stripe event type: %s", event.Type)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleCheckoutCompleted(r *http.Request, event stripe.Event) {
|
||||
var session stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
|
||||
log.Printf("Error parsing checkout session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
userID := session.ClientReferenceID
|
||||
if userID == "" {
|
||||
log.Println("Checkout session missing client_reference_id")
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if session.Customer != nil {
|
||||
customerID = session.Customer.ID
|
||||
}
|
||||
subscriptionID := ""
|
||||
if session.Subscription != nil {
|
||||
subscriptionID = session.Subscription.ID
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
if err := h.users.UpdateStripeCustomer(r.Context(), userID, customerID); err != nil {
|
||||
log.Printf("Failed to save Stripe customer ID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if subscriptionID != "" && customerID != "" {
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, subscriptionID, "active"); err != nil {
|
||||
log.Printf("Failed to activate subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Checkout completed for user %s, customer %s, subscription %s", userID, customerID, subscriptionID)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionUpdated(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription update: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
status := string(sub.Status)
|
||||
if err := h.users.ActivateSubscription(r.Context(), customerID, sub.ID, status); err != nil {
|
||||
log.Printf("Failed to update subscription status: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription %s updated to %s for customer %s", sub.ID, status, customerID)
|
||||
}
|
||||
|
||||
func (h *StripeHandler) handleSubscriptionDeleted(r *http.Request, event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
log.Printf("Error parsing subscription deletion: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
customerID := ""
|
||||
if sub.Customer != nil {
|
||||
customerID = sub.Customer.ID
|
||||
}
|
||||
if customerID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.users.UpdateSubscriptionStatus(r.Context(), customerID, "canceled"); err != nil {
|
||||
log.Printf("Failed to mark subscription canceled: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription canceled for customer %s", customerID)
|
||||
}
|
||||
152
backend/internal/middleware/auth.go
Normal file
152
backend/internal/middleware/auth.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package middleware provides HTTP middleware for authentication and context injection.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "github.com/kjannette/koin-ping/backend/internal/firebase"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "user_id"
|
||||
UserEmailKey contextKey = "user_email"
|
||||
)
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Authenticate verifies the Firebase ID token and auto-provisions a local user
|
||||
// record. The local user UUID (not the Firebase UID) is placed into context so
|
||||
// all downstream handlers use it as the canonical user identifier.
|
||||
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Println("No Authorization header or invalid format")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "No authentication token provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if token == "" {
|
||||
log.Println("Empty token")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Invalid token format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Verifying Firebase token...")
|
||||
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("Token verification failed: %v", err)
|
||||
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "expired") {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "TOKEN_EXPIRED",
|
||||
Message: "Authentication token has expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Failed to verify authentication token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
firebaseUID := decoded.UID
|
||||
email, _ := decoded.Claims["email"].(string)
|
||||
|
||||
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
|
||||
if err != nil {
|
||||
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "Failed to initialize user account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
|
||||
ctx = context.WithValue(ctx, UserEmailKey, email)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireSubscription blocks requests from users without an active subscription.
|
||||
// Must be applied after Authenticate.
|
||||
func RequireSubscription(userModel *models.UserModel) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "UNAUTHORIZED",
|
||||
Message: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userModel.GetByID(r.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("RequireSubscription: failed to load user %s: %v", userID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
||||
Error: "INTERNAL_ERROR",
|
||||
Message: "Failed to verify subscription",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if user.SubscriptionStatus != "active" && user.SubscriptionStatus != "trialing" {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "SUBSCRIPTION_REQUIRED",
|
||||
Message: "An active subscription is required to use this feature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetUserEmail(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(UserEmailKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AddressModel struct {
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AlertEventModel struct {
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type AlertRuleModel struct {
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type CheckpointModel struct {
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type NotificationConfigModel struct {
|
||||
89
backend/internal/models/user.go
Normal file
89
backend/internal/models/user.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserModel struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewUserModel(pool *pgxpool.Pool) *UserModel {
|
||||
return &UserModel{pool: pool}
|
||||
}
|
||||
|
||||
const userColumns = `id, firebase_uid, email, display_name,
|
||||
stripe_customer_id, stripe_subscription_id, subscription_status,
|
||||
subscription_created_at, created_at, updated_at`
|
||||
|
||||
func scanUser(row pgx.Row) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(
|
||||
&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName,
|
||||
&u.StripeCustomerID, &u.StripeSubscriptionID, &u.SubscriptionStatus,
|
||||
&u.SubscriptionCreatedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
|
||||
// creating one if it doesn't exist yet. On conflict (returning user) the
|
||||
// updated_at timestamp is refreshed.
|
||||
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`INSERT INTO users (firebase_uid, email)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING `+userColumns,
|
||||
firebaseUID, email,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
row := m.pool.QueryRow(ctx,
|
||||
`SELECT `+userColumns+` FROM users WHERE id = $1`, id,
|
||||
)
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateStripeCustomer(ctx context.Context, userID, stripeCustomerID string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET stripe_customer_id = $2, updated_at = NOW() WHERE id = $1`,
|
||||
userID, stripeCustomerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) ActivateSubscription(ctx context.Context, stripeCustomerID, subscriptionID, status string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users
|
||||
SET stripe_subscription_id = $2,
|
||||
subscription_status = $3,
|
||||
subscription_created_at = COALESCE(subscription_created_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, subscriptionID, status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *UserModel) UpdateSubscriptionStatus(ctx context.Context, stripeCustomerID, status string) error {
|
||||
_, err := m.pool.Exec(ctx,
|
||||
`UPDATE users SET subscription_status = $2, updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
stripeCustomerID, status,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -21,17 +21,14 @@ const (
|
||||
colorBlue = 0x0099ff
|
||||
)
|
||||
|
||||
// discordHTTPClient is a shared HTTP client with a timeout for Discord requests.
|
||||
var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||
}
|
||||
|
||||
// DiscordNotifier sends alert notifications via a Discord webhook.
|
||||
// sends alert notifications via a Discord webhook.
|
||||
type DiscordNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
// Send implements Notifier for Discord.
|
||||
func (d *DiscordNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendDiscordNotification(d.WebhookURL, message, meta)
|
||||
return err
|
||||
@@ -104,8 +101,12 @@ func SendDiscordNotification(webhookURL, message string, meta AlertMetadata) (bo
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("discord webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -10,14 +10,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmailNotifier sends alert notifications via email (Resend).
|
||||
type EmailNotifier struct {
|
||||
APIKey string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// Send implements Notifier for email.
|
||||
func (e *EmailNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||
_, err := SendEmailNotification(e.APIKey, e.From, e.To, message, meta)
|
||||
return err
|
||||
@@ -99,8 +97,12 @@ func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
38
backend/internal/notifications/notifier.go
Normal file
38
backend/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AlertMetadata holds context about the alert being sent.
|
||||
type AlertMetadata struct {
|
||||
TxHash string
|
||||
AddressLabel string
|
||||
AlertType string
|
||||
Address string
|
||||
}
|
||||
|
||||
type Notifier interface {
|
||||
Send(ctx context.Context, message string, meta AlertMetadata) error
|
||||
}
|
||||
|
||||
// PermanentError wraps errors that should not be retried (e.g. 401, 403, 404).
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var p *PermanentError
|
||||
return errors.As(err, &p)
|
||||
}
|
||||
|
||||
func isPermanentStatusCode(code int) bool {
|
||||
return code == http.StatusUnauthorized ||
|
||||
code == http.StatusForbidden ||
|
||||
code == http.StatusNotFound ||
|
||||
code == http.StatusMethodNotAllowed ||
|
||||
code == http.StatusGone
|
||||
}
|
||||
@@ -87,8 +87,12 @@ func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -63,8 +63,12 @@ func SendTelegramNotification(botToken, chatID, message string, meta AlertMetada
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||
if isPermanentStatusCode(resp.StatusCode) {
|
||||
return false, &PermanentError{Err: err}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
rpcTimeoutMS = 30000
|
||||
rpcMaxRetries = 3
|
||||
rpcRetryBaseMS = 1000
|
||||
rpcRetryBaseMS = 2000
|
||||
)
|
||||
|
||||
type JsonRpcEthereum struct {
|
||||
@@ -242,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
|
||||
return hexToDecimalString(hexBalance), nil
|
||||
}
|
||||
|
||||
const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
|
||||
const minTransferTopics = 3
|
||||
|
||||
type rpcLog struct {
|
||||
Address string `json:"address"`
|
||||
Topics []string `json:"topics"`
|
||||
Data string `json:"data"`
|
||||
BlockNumber string `json:"blockNumber"`
|
||||
TxHash string `json:"transactionHash"`
|
||||
}
|
||||
|
||||
// GetTokenTransfers fetches ERC-20 Transfer events for a given address
|
||||
// across a block range, covering both incoming and outgoing transfers.
|
||||
func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) {
|
||||
paddedAddr := padAddress(address)
|
||||
hexFrom := fmt.Sprintf("0x%x", fromBlock)
|
||||
hexTo := fmt.Sprintf("0x%x", toBlock)
|
||||
|
||||
incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch incoming token transfers: %w", err)
|
||||
}
|
||||
|
||||
outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch outgoing token transfers: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var txs []domain.NormalizedTx
|
||||
|
||||
for _, entry := range append(incomingLogs, outgoingLogs...) {
|
||||
key := entry.TxHash + "|" + entry.Address + "|" + entry.Data
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
tx, parseErr := parseTransferLog(entry)
|
||||
if parseErr != nil {
|
||||
log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr)
|
||||
continue
|
||||
}
|
||||
txs = append(txs, tx)
|
||||
}
|
||||
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) {
|
||||
topics := make([]interface{}, minTransferTopics)
|
||||
topics[0] = erc20TransferTopic
|
||||
|
||||
if fromAddr != "" {
|
||||
topics[1] = fromAddr
|
||||
}
|
||||
if toAddr != "" {
|
||||
topics[2] = toAddr
|
||||
}
|
||||
|
||||
filter := map[string]interface{}{
|
||||
"fromBlock": fromBlock,
|
||||
"toBlock": toBlock,
|
||||
"topics": topics,
|
||||
}
|
||||
|
||||
result, err := j.callRPC(ctx, "eth_getLogs", filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []rpcLog
|
||||
if err := json.Unmarshal(result, &logs); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal logs: %w", err)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) {
|
||||
if len(entry.Topics) < minTransferTopics {
|
||||
return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics))
|
||||
}
|
||||
|
||||
from := topicToAddress(entry.Topics[1])
|
||||
to := topicToAddress(entry.Topics[2])
|
||||
tokenValue := hexToDecimalString(entry.Data)
|
||||
blockNumber, _ := hexToInt(entry.BlockNumber)
|
||||
|
||||
contractAddr := strings.ToLower(entry.Address)
|
||||
tokenInfo, known := LookupToken(contractAddr)
|
||||
|
||||
tx := domain.NormalizedTx{
|
||||
Hash: entry.TxHash,
|
||||
From: from,
|
||||
To: &to,
|
||||
Value: "0",
|
||||
BlockNumber: blockNumber,
|
||||
TokenContract: &contractAddr,
|
||||
TokenValue: &tokenValue,
|
||||
}
|
||||
|
||||
if known {
|
||||
tx.TokenSymbol = &tokenInfo.Symbol
|
||||
tx.TokenDecimals = &tokenInfo.Decimals
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func padAddress(addr string) string {
|
||||
clean := strings.TrimPrefix(strings.ToLower(addr), "0x")
|
||||
const addressHexLen = 64
|
||||
return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean
|
||||
}
|
||||
|
||||
func topicToAddress(topic string) string {
|
||||
clean := strings.TrimPrefix(topic, "0x")
|
||||
const ethAddrLen = 40
|
||||
if len(clean) > ethAddrLen {
|
||||
clean = clean[len(clean)-ethAddrLen:]
|
||||
}
|
||||
return "0x" + strings.ToLower(clean)
|
||||
}
|
||||
|
||||
func hexToInt(hex string) (int, error) {
|
||||
hex = strings.TrimPrefix(hex, "0x")
|
||||
n, ok := new(big.Int).SetString(hex, 16)
|
||||
@@ -3,7 +3,7 @@ package ethereum
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
)
|
||||
|
||||
// EthereumObserver defines the interface for blockchain interaction.
|
||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||
GetBalance(ctx context.Context, address string) (string, error)
|
||||
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||
}
|
||||
34
backend/internal/protocols/ethereum/tokens.go
Normal file
34
backend/internal/protocols/ethereum/tokens.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package ethereum
|
||||
|
||||
import "strings"
|
||||
|
||||
// TokenInfo holds metadata for a known ERC-20 token contract.
|
||||
type TokenInfo struct {
|
||||
Symbol string
|
||||
Decimals int
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var wellKnownTokens = map[string]TokenInfo{
|
||||
"0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6},
|
||||
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6},
|
||||
"0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18},
|
||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18},
|
||||
"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8},
|
||||
"0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18},
|
||||
"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18},
|
||||
"0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18},
|
||||
"0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18},
|
||||
"0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18},
|
||||
"0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18},
|
||||
"0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18},
|
||||
"0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18},
|
||||
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9},
|
||||
"0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18},
|
||||
}
|
||||
|
||||
// LookupToken returns metadata for a known token contract, if found.
|
||||
func LookupToken(contractAddress string) (TokenInfo, bool) {
|
||||
info, ok := wellKnownTokens[strings.ToLower(contractAddress)]
|
||||
return info, ok
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
|
||||
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||
|
||||
// EmailDigestService handles email setup and digest sending via Resend.
|
||||
// handles email setup and digest sending via Resend.
|
||||
type EmailDigestService struct {
|
||||
apiKey string
|
||||
fromAddress string
|
||||
@@ -66,7 +66,6 @@ func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||
return s.send(toAddress, "Koin Ping — Email Alerts Configured", html)
|
||||
}
|
||||
|
||||
// SendDigest compiles recent alert events for a user and sends a digest email.
|
||||
func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress string) error {
|
||||
if !s.Configured() {
|
||||
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||
@@ -131,8 +130,6 @@ func (s *EmailDigestService) SendDigest(ctx context.Context, userID, toAddress s
|
||||
return s.send(toAddress, subject, html)
|
||||
}
|
||||
|
||||
// SendDigestsForAllUsers sends a digest email to every user that has
|
||||
// notifications enabled and an email configured.
|
||||
func (s *EmailDigestService) SendDigestsForAllUsers(ctx context.Context) (int, error) {
|
||||
if !s.Configured() {
|
||||
return 0, nil
|
||||
@@ -4,19 +4,23 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/notifications"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/wei"
|
||||
)
|
||||
|
||||
const (
|
||||
notificationTimeout = 30 * time.Second
|
||||
notificationMaxRetries = 3
|
||||
notificationRetryBase = time.Second
|
||||
maxConcurrentNotifications = 5
|
||||
)
|
||||
|
||||
type EvaluatorService struct {
|
||||
@@ -27,6 +31,8 @@ type EvaluatorService struct {
|
||||
notifConfigs *models.NotificationConfigModel
|
||||
resendAPIKey string
|
||||
emailFrom string
|
||||
notifSem *semaphore.Weighted
|
||||
notifWg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewEvaluatorService(
|
||||
@@ -46,6 +52,7 @@ func NewEvaluatorService(
|
||||
notifConfigs: notifConfigs,
|
||||
resendAPIKey: resendAPIKey,
|
||||
emailFrom: emailFrom,
|
||||
notifSem: semaphore.NewWeighted(maxConcurrentNotifications),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +196,14 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
if addr != nil {
|
||||
userID := addr.UserID
|
||||
address := addr.Address
|
||||
if err := s.notifSem.Acquire(ctx, 1); err != nil {
|
||||
log.Printf("Failed to acquire notification semaphore for rule %d: %v", rule.ID, err)
|
||||
return nil
|
||||
}
|
||||
s.notifWg.Add(1)
|
||||
go func() {
|
||||
defer s.notifSem.Release(1)
|
||||
defer s.notifWg.Done()
|
||||
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||
defer cancel()
|
||||
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||
@@ -199,6 +213,11 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitForNotifications blocks until all in-flight notification goroutines finish.
|
||||
func (s *EvaluatorService) WaitForNotifications() {
|
||||
s.notifWg.Wait()
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||
var notifiers []notifications.Notifier
|
||||
|
||||
@@ -242,8 +261,14 @@ func sendWithRetry(ctx context.Context, n notifications.Notifier, message string
|
||||
}
|
||||
|
||||
if err := n.Send(ctx, message, meta); err != nil {
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
lastErr = err
|
||||
|
||||
if notifications.IsPermanent(err) {
|
||||
log.Printf("Permanent notification failure, skipping retries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -281,6 +306,10 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
||||
}
|
||||
|
||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||
if obs.IsTokenTransfer() {
|
||||
return s.buildTokenMessage(rule, obs)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||
@@ -305,3 +334,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
|
||||
return "Alert triggered"
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTokenDecimals = 18
|
||||
|
||||
func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||
symbol := "tokens"
|
||||
if obs.TokenSymbol != nil {
|
||||
symbol = *obs.TokenSymbol
|
||||
}
|
||||
|
||||
amount := "unknown"
|
||||
if obs.TokenValue != nil {
|
||||
decimals := defaultTokenDecimals
|
||||
if obs.TokenDecimals != nil {
|
||||
decimals = *obs.TokenDecimals
|
||||
}
|
||||
amount = wei.FormatTokenAmount(*obs.TokenValue, decimals)
|
||||
}
|
||||
|
||||
switch rule.Type {
|
||||
case domain.AlertIncomingTx:
|
||||
return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol)
|
||||
case domain.AlertOutgoingTx:
|
||||
return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol)
|
||||
case domain.AlertLargeTransfer:
|
||||
threshold := float64(0)
|
||||
if rule.Threshold != nil {
|
||||
threshold = *rule.Threshold
|
||||
}
|
||||
return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold)
|
||||
default:
|
||||
return fmt.Sprintf("Token transfer: %s %s", amount, symbol)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend-go/internal/protocols/ethereum"
|
||||
"github.com/kjannette/koin-ping/backend/internal/domain"
|
||||
"github.com/kjannette/koin-ping/backend/internal/models"
|
||||
"github.com/kjannette/koin-ping/backend/internal/protocols/ethereum"
|
||||
)
|
||||
|
||||
const maxBlocksPerRun = 100
|
||||
@@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres
|
||||
}
|
||||
}
|
||||
|
||||
tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching token transfers for %s: %v", addr.Address, err)
|
||||
} else {
|
||||
for _, tx := range tokenTxs {
|
||||
observations = append(observations, createObservedTx(tx, addr))
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
||||
}
|
||||
|
||||
// FormatTokenAmount formats a raw token amount using the token's decimal places.
|
||||
// For example, 1000000 USDT (6 decimals) becomes "1".
|
||||
func FormatTokenAmount(rawValue string, tokenDecimals int) string {
|
||||
if rawValue == "" || rawValue == "0" {
|
||||
return "0"
|
||||
}
|
||||
|
||||
n, ok := new(big.Int).SetString(rawValue, 10)
|
||||
if !ok {
|
||||
return "0"
|
||||
}
|
||||
|
||||
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd
|
||||
whole := new(big.Int).Div(n, divisor)
|
||||
remainder := new(big.Int).Mod(n, divisor)
|
||||
|
||||
if remainder.Sign() == 0 {
|
||||
return addThousandsSeparators(whole.String())
|
||||
}
|
||||
|
||||
fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String())
|
||||
fracStr = strings.TrimRight(fracStr, "0")
|
||||
const maxDisplayDecimals = 4
|
||||
if len(fracStr) > maxDisplayDecimals {
|
||||
fracStr = fracStr[:maxDisplayDecimals]
|
||||
}
|
||||
|
||||
return addThousandsSeparators(whole.String()) + "." + fracStr
|
||||
}
|
||||
|
||||
func addThousandsSeparators(s string) string {
|
||||
if len(s) <= 3 { //nolint:mnd
|
||||
return s
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
offset := len(s) % 3 //nolint:mnd
|
||||
if offset > 0 {
|
||||
result.WriteString(s[:offset])
|
||||
}
|
||||
|
||||
for i := offset; i < len(s); i += 3 { //nolint:mnd
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3]) //nolint:mnd
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
BIN
frontend/public/ping.png
Normal file
BIN
frontend/public/ping.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -1,35 +1,38 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./contexts/AuthContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Login from "./pages/Login";
|
||||
import Login from "./pages/login/Login";
|
||||
import Signup from "./pages/Signup";
|
||||
import Addresses from "./pages/Addresses";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import AlertHistory from "./pages/AlertHistory";
|
||||
import Subscribe from "./pages/subscribe/subscribe";
|
||||
import Addresses from "./pages/addresses/Addresses";
|
||||
import Alerts from "./pages/alerts/Alerts";
|
||||
import AlertHistory from "./pages/alertHistory/AlertHistory";
|
||||
|
||||
export default function App() {
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/history" element={<AlertHistory />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/login" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
<Route path="/alerts" element={<Alerts />} />
|
||||
<Route path="/alertevents" element={<AlertHistory />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
41
frontend/src/api/stripe.jsx
Normal file
41
frontend/src/api/stripe.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getAuthHeaders } from "./authHeaders";
|
||||
import { API_BASE } from "./config";
|
||||
|
||||
export async function createCheckoutSession() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/create-checkout-session`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to create checkout session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function verifyCheckoutSession(sessionId) {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/verify-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to verify checkout session");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSubscriptionStatus() {
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(`${API_BASE}/stripe/subscription-status`, {
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || "Failed to get subscription status");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default function AddressForm({ onSubmit }) {
|
||||
placeholder="Treasury, Cold Wallet, etc."
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
<Button type="submit" disabled={!canSubmit} className="mt-md">
|
||||
Add Address
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
7
frontend/src/components/AlertForm.css
Normal file
7
frontend/src/components/AlertForm.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.alert-form__options {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-form__radio {
|
||||
display: block;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import Input from "./Input";
|
||||
import Button from "./Button";
|
||||
import "./AlertForm.css";
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ value: "incoming_tx", label: "Incoming transaction" },
|
||||
@@ -36,9 +37,9 @@ export default function AlertForm({ onSubmit }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<div className="alert-form__options">
|
||||
{ALERT_TYPES.map((opt) => (
|
||||
<label key={opt.value} style={{ display: "block" }}>
|
||||
<label key={opt.value} className="alert-form__radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="alertType"
|
||||
|
||||
14
frontend/src/components/Button.css
Normal file
14
frontend/src/components/Button.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import "./Button.css";
|
||||
|
||||
export default function Button({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = "button",
|
||||
variant = "primary",
|
||||
className = "",
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "1rem",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
className={`btn btn--${variant} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
21
frontend/src/components/Input.css
Normal file
21
frontend/src/components/Input.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.input__label {
|
||||
display: block;
|
||||
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.input__control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.input__control:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./Input.css";
|
||||
|
||||
export default function Input({
|
||||
label,
|
||||
type = "text",
|
||||
@@ -7,12 +9,12 @@ export default function Input({
|
||||
step,
|
||||
min,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className = "",
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: "block", marginBottom: "1rem" }}>
|
||||
<div style={{ marginBottom: "0.25rem", fontSize: "0.9rem" }}>
|
||||
{label}
|
||||
</div>
|
||||
<label className={`form-field ${className}`}>
|
||||
<div className="input__label">{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
@@ -20,12 +22,9 @@ export default function Input({
|
||||
step={step}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
className="input__control"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
56
frontend/src/components/Navbar.css
Normal file
56
frontend/src/components/Navbar.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background-color: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-brand);
|
||||
margin-right: 2rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.navbar__links {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.navbar__link {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
font-size: 1.425rem;
|
||||
font-weight: 200;
|
||||
color: var(--color-text-subtle);
|
||||
background-color: transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar__link--active {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.navbar__user {
|
||||
font-size: 1.275rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.navbar__logout {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 1.275rem;
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import "./Navbar.css";
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/addresses", label: "Addresses" },
|
||||
{ to: "/alerts", label: "Configure Alerts" },
|
||||
{ to: "/history", label: "Alert History" },
|
||||
{ to: "/alertevents", label: "Alert Event History" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -14,43 +15,17 @@ export default function Navbar() {
|
||||
if (!currentUser) return null;
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "1rem 2rem",
|
||||
marginBottom: "1.5rem",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderBottom: "1px solid #333",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
fontSize: "1.1rem",
|
||||
color: "#fff",
|
||||
marginRight: "2rem",
|
||||
letterSpacing: "0.5px",
|
||||
}}>
|
||||
Koin Ping
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<nav className="navbar">
|
||||
<div className="flex flex--center gap-sm">
|
||||
<span className="navbar__brand">Koin Ping</span>
|
||||
<div className="navbar__links">
|
||||
{navLinks.map(({ to, label }) => {
|
||||
const isActive = location.pathname === to;
|
||||
return (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
style={{
|
||||
padding: "0.5rem 1.25rem",
|
||||
borderRadius: "6px",
|
||||
textDecoration: "none",
|
||||
fontSize: "1.425rem",
|
||||
fontWeight: isActive ? 600 : 200,
|
||||
color: isActive ? "#fff" : "#999",
|
||||
backgroundColor: isActive ? "#333" : "transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
className={`navbar__link ${isActive ? "navbar__link--active" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
@@ -59,24 +34,9 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1.25rem" }}>
|
||||
<span style={{ fontSize: "1.275rem", color: "#777" }}>
|
||||
{currentUser.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
padding: "0.4rem 1rem",
|
||||
fontSize: "1.275rem",
|
||||
fontWeight: 400,
|
||||
backgroundColor: "transparent",
|
||||
color: "#999",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex--center gap-lg">
|
||||
<span className="navbar__user">{currentUser.email}</span>
|
||||
<button onClick={logout} className="navbar__logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
||||
|
||||
:root {
|
||||
--color-primary: #0066cc;
|
||||
--color-primary-hover: #0077ee;
|
||||
--color-brand: #e62525;
|
||||
--color-success: #28a745;
|
||||
--color-success-text: #00ff00;
|
||||
--color-success-bg: #00ff0020;
|
||||
--color-success-border: #00ff00;
|
||||
--color-error: #ff6666;
|
||||
--color-error-bg: #ff000020;
|
||||
--color-error-border: #ff0000;
|
||||
--color-warning: #ffcc44;
|
||||
--color-warning-bg: #3a2e00;
|
||||
--color-warning-border: #aa7700;
|
||||
--color-danger: #cc6666;
|
||||
--color-danger-border: #884444;
|
||||
|
||||
--color-bg: #1a1a1a;
|
||||
--color-bg-elevated: #242424;
|
||||
--color-bg-card: #333;
|
||||
--color-bg-section: #2a2a2a;
|
||||
|
||||
--color-border: #444;
|
||||
--color-border-light: #333;
|
||||
--color-border-section: #3a3a3a;
|
||||
|
||||
--color-text: white;
|
||||
--color-text-muted: #b3b3b3;
|
||||
--color-text-dimmed: #808080;
|
||||
--color-text-subtle: #999;
|
||||
--color-text-label: #ccc;
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -21,13 +59,300 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
input, select, textarea, button {
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
/* ── Page Layouts ─────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page--wide {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ── Cards & Sections ─────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-section);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-section);
|
||||
}
|
||||
|
||||
/* ── Form Controls ────────────────────────────────────────── */
|
||||
|
||||
.form-field {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-field--last {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-label);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||
}
|
||||
|
||||
.form-control--mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn--success {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-danger-border);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Feedback Alerts ──────────────────────────────────────── */
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background-color: var(--color-error-bg);
|
||||
border: 1px solid var(--color-error-border);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
background-color: var(--color-success-bg);
|
||||
border: 1px solid var(--color-success-border);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.alert--warning {
|
||||
background-color: var(--color-warning-bg);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* ── Lists ────────────────────────────────────────────────── */
|
||||
|
||||
.list-unstyled {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list-item--card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
/* ── Typography Helpers ───────────────────────────────────── */
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.text-dimmed {
|
||||
color: var(--color-text-dimmed);
|
||||
}
|
||||
|
||||
.text-mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 1.035rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.978rem;
|
||||
}
|
||||
|
||||
.help-link {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Flex Utilities ───────────────────────────────────────── */
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex--between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex--center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex--column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex--wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gap-xs {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-md {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gap-lg {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Spacing Helpers ──────────────────────────────────────── */
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-xl {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mt-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-white-sm {
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AddressForm from "../components/AddressForm";
|
||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
|
||||
|
||||
export default function Addresses() {
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
|
||||
// Load addresses on mount
|
||||
useEffect(() => {
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAddresses();
|
||||
setAddresses(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch addresses:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAddresses();
|
||||
}, []);
|
||||
|
||||
// Handle new address submission
|
||||
async function handleAddressSubmit(data) {
|
||||
try {
|
||||
const newAddress = await createAddress(data);
|
||||
setAddresses((prev) => [...prev, newAddress]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id, label) {
|
||||
const displayName = label || "this address";
|
||||
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAddress(id);
|
||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditStart(addr) {
|
||||
setEditingId(addr.id);
|
||||
setEditLabel(addr.label ?? "");
|
||||
}
|
||||
|
||||
async function handleEditSave(id) {
|
||||
try {
|
||||
const updated = await updateAddress(id, { label: editLabel || null });
|
||||
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||
<h1>Tracked Addresses</h1>
|
||||
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Existing Addresses</h2>
|
||||
{loading && <p>Loading addresses...</p>}
|
||||
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
||||
{!loading && !error && addresses.length === 0 && (
|
||||
<p style={{ color: "#808080" }}>
|
||||
No addresses tracked yet. Add one above to get started.
|
||||
</p>
|
||||
)}
|
||||
{addresses.length > 0 && (
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{addresses.map((addr, index) => (
|
||||
<li
|
||||
key={addr.id || index}
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "0.5rem",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#333",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId === addr.id ? (
|
||||
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
style={{
|
||||
background: "#444",
|
||||
border: "1px solid #666",
|
||||
borderRadius: "3px",
|
||||
color: "#fff",
|
||||
padding: "0.25rem 0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave(addr.id);
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEditSave(addr.id)}
|
||||
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{addr.label || "Unlabeled"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditStart(addr)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6699cc",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1.035rem",
|
||||
color: "#b3b3b3",
|
||||
}}
|
||||
>
|
||||
{addr.address}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(addr.id, addr.label)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "1px solid #884444",
|
||||
color: "#cc6666",
|
||||
borderRadius: "3px",
|
||||
padding: "0.3rem 0.7rem",
|
||||
fontSize: "0.85rem",
|
||||
marginLeft: "1rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getAlertEvents } from "../api/alertEvents";
|
||||
|
||||
export default function AlertHistory() {
|
||||
const [alertEvents, setAlertEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Fetch alert events on mount
|
||||
useEffect(() => {
|
||||
async function fetchAlertEvents() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAlertEvents();
|
||||
setAlertEvents(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch alert events:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAlertEvents();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: "2rem" }}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", color: "red" }}>Error: {error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||
<h1>Recent Alerts</h1>
|
||||
|
||||
{alertEvents.length === 0 ? (
|
||||
<p style={{ color: "#808080" }}>No alerts yet</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{alertEvents.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "0.75rem",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#333",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
{event.message}
|
||||
</div>
|
||||
{event.address_label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.035rem",
|
||||
color: "#808080",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Address: {event.address_label}
|
||||
</div>
|
||||
)}
|
||||
<small style={{ color: "#b3b3b3" }}>
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to format timestamp for display
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
@@ -1,842 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AlertForm from "../components/AlertForm";
|
||||
import Button from "../components/Button";
|
||||
import { getAddresses } from "../api/addresses";
|
||||
import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
deleteAlert,
|
||||
} from "../api/alerts";
|
||||
import {
|
||||
getNotificationConfig,
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
setupEmail,
|
||||
sendEmailDigest,
|
||||
} from "../api/notificationConfig";
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "0.9rem",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
fontFamily: "monospace",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
display: "block",
|
||||
marginBottom: "0.25rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#ccc",
|
||||
};
|
||||
|
||||
const helpLinkStyle = { color: "#0066cc", fontSize: "0.85rem" };
|
||||
|
||||
const sectionStyle = {
|
||||
marginBottom: "1.5rem",
|
||||
padding: "1rem",
|
||||
backgroundColor: "#2a2a2a",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #3a3a3a",
|
||||
};
|
||||
|
||||
export default function Alerts() {
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Notification config state
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
||||
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||
const [telegramChatId, setTelegramChatId] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||
const [notificationError, setNotificationError] = useState(null);
|
||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
||||
const [testingChannels, setTestingChannels] = useState(false);
|
||||
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||
const [sendingDigest, setSendingDigest] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const addressData = await getAddresses();
|
||||
setAddresses(addressData);
|
||||
if (addressData.length > 0) {
|
||||
setSelectedAddressId(addressData[0].id);
|
||||
}
|
||||
|
||||
const configData = await getNotificationConfig();
|
||||
setNotificationEnabled(
|
||||
configData.notification_enabled !== false,
|
||||
);
|
||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
||||
setTelegramBotToken(configData.telegram_bot_token || "");
|
||||
setTelegramChatId(configData.telegram_chat_id || "");
|
||||
setEmail(configData.email || "");
|
||||
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
||||
|
||||
const hasSaved =
|
||||
!!configData.discord_webhook_url ||
|
||||
!!configData.telegram_bot_token ||
|
||||
!!configData.telegram_chat_id ||
|
||||
!!configData.email ||
|
||||
!!configData.slack_webhook_url;
|
||||
setHasExistingConfig(hasSaved);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch data:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAddressId) {
|
||||
setAlerts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchAlerts() {
|
||||
try {
|
||||
const data = await getAlerts(selectedAddressId);
|
||||
setAlerts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch alerts:", err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAlerts();
|
||||
}, [selectedAddressId]);
|
||||
|
||||
async function handleAlertSubmit(data) {
|
||||
if (!selectedAddressId) return;
|
||||
|
||||
try {
|
||||
const newAlert = await createAlert(selectedAddressId, data);
|
||||
setAlerts((prev) => [...prev, newAlert]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAlert(alertId, currentStatus) {
|
||||
try {
|
||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||
setAlerts((prev) =>
|
||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
||||
);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAlert(alertId) {
|
||||
try {
|
||||
await deleteAlert(alertId);
|
||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNotificationConfig() {
|
||||
if (hasExistingConfig) {
|
||||
const confirmed = window.confirm(
|
||||
"This will overwrite your previously saved notification settings. Continue?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNotificationLoading(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const config = {
|
||||
notification_enabled: notificationEnabled,
|
||||
discord_webhook_url: discordWebhookUrl || null,
|
||||
telegram_bot_token: telegramBotToken || null,
|
||||
telegram_chat_id: telegramChatId || null,
|
||||
email: email || null,
|
||||
slack_webhook_url: slackWebhookUrl || null,
|
||||
};
|
||||
|
||||
await updateNotificationConfig(config);
|
||||
setHasExistingConfig(true);
|
||||
setNotificationSuccess("Notification settings saved!");
|
||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
console.error("Failed to save notification config:", err);
|
||||
} finally {
|
||||
setNotificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestChannels() {
|
||||
try {
|
||||
setTestingChannels(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const data = await testNotificationChannels();
|
||||
const results = data.results || [];
|
||||
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const succeeded = results.filter((r) => r.success);
|
||||
|
||||
if (failed.length === 0 && succeeded.length > 0) {
|
||||
setNotificationSuccess(
|
||||
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
||||
);
|
||||
} else if (failed.length > 0 && succeeded.length > 0) {
|
||||
setNotificationSuccess(
|
||||
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||
);
|
||||
} else if (failed.length > 0) {
|
||||
setNotificationError(
|
||||
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setNotificationSuccess(null);
|
||||
setNotificationError(null);
|
||||
}, 6000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setTestingChannels(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetupEmail() {
|
||||
try {
|
||||
setSettingUpEmail(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
await handleSaveNotificationConfig();
|
||||
|
||||
const result = await setupEmail();
|
||||
setNotificationSuccess(
|
||||
result.message || "Confirmation email sent!",
|
||||
);
|
||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setSettingUpEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendDigest() {
|
||||
try {
|
||||
setSendingDigest(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const result = await sendEmailDigest();
|
||||
setNotificationSuccess(result.message || "Digest email sent!");
|
||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setSendingDigest(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: "2rem" }}>Loading addresses...</div>;
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: "2rem" }}>
|
||||
<p>
|
||||
No addresses tracked yet. Add an address first to create
|
||||
alerts.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "1400px", margin: "0 auto", padding: "2rem" }}>
|
||||
<h1 style={{ marginBottom: "2rem" }}>
|
||||
Alert Rules & Notifications
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "2rem",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{/* LEFT COLUMN: Alert Rules */}
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
||||
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<label
|
||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
||||
>
|
||||
<strong>Select Address:</strong>
|
||||
</label>
|
||||
<select
|
||||
value={selectedAddressId || ""}
|
||||
onChange={(e) =>
|
||||
setSelectedAddressId(Number(e.target.value))
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{addresses.map((addr) => (
|
||||
<option key={addr.id} value={addr.id}>
|
||||
{addr.label || "Unlabeled"} - {addr.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedAddress && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "2rem",
|
||||
backgroundColor: "#333",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #444",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.035rem",
|
||||
color: "#b3b3b3",
|
||||
}}
|
||||
>
|
||||
Managing alerts for:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{selectedAddress.label || "Unlabeled"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "1.035rem",
|
||||
color: "#b3b3b3",
|
||||
}}
|
||||
>
|
||||
{selectedAddress.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
{error && (
|
||||
<p style={{ color: "red" }}>
|
||||
Error: {error}
|
||||
</p>
|
||||
)}
|
||||
{alerts.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>
|
||||
No alert rules defined yet. Create one
|
||||
above.
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.id}
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "0.5rem",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#333",
|
||||
opacity: alert.enabled
|
||||
? 1
|
||||
: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent:
|
||||
"space-between",
|
||||
alignItems:
|
||||
"flex-start",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight:
|
||||
"bold",
|
||||
marginBottom:
|
||||
"0.25rem",
|
||||
}}
|
||||
>
|
||||
{formatAlertType(
|
||||
alert.type,
|
||||
)}
|
||||
</div>
|
||||
{alert.threshold && (
|
||||
<div
|
||||
style={{
|
||||
fontSize:
|
||||
"1.035rem",
|
||||
color: "#b3b3b3",
|
||||
}}
|
||||
>
|
||||
Threshold:{" "}
|
||||
{
|
||||
alert.threshold
|
||||
}{" "}
|
||||
ETH
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize:
|
||||
"0.978rem",
|
||||
color: "#808080",
|
||||
marginTop:
|
||||
"0.25rem",
|
||||
}}
|
||||
>
|
||||
Status:{" "}
|
||||
{alert.enabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(
|
||||
alert.id,
|
||||
alert.enabled,
|
||||
)
|
||||
}
|
||||
>
|
||||
{alert.enabled
|
||||
? "Disable"
|
||||
: "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleDeleteAlert(
|
||||
alert.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Notification Settings */}
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Notification Settings</h2>
|
||||
|
||||
{notificationSuccess && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#00ff0020",
|
||||
border: "1px solid #00ff00",
|
||||
borderRadius: "4px",
|
||||
color: "#00ff00",
|
||||
}}
|
||||
>
|
||||
{notificationSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#ff000020",
|
||||
border: "1px solid #ff0000",
|
||||
borderRadius: "4px",
|
||||
color: "#ff6666",
|
||||
}}
|
||||
>
|
||||
{notificationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Master toggle */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "1.5rem",
|
||||
padding: "1rem",
|
||||
backgroundColor: "#333",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationEnabled}
|
||||
onChange={(e) =>
|
||||
setNotificationEnabled(e.target.checked)
|
||||
}
|
||||
style={{
|
||||
marginRight: "0.5rem",
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
Enable Notifications
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.978rem",
|
||||
color: "#808080",
|
||||
marginTop: "0.5rem",
|
||||
marginLeft: "26px",
|
||||
}}
|
||||
>
|
||||
Master switch for all notification channels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All channel settings — hidden when master toggle is off */}
|
||||
<div
|
||||
style={{
|
||||
display: notificationEnabled ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Telegram */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||
Telegram
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Bot Token</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramBotToken}
|
||||
onChange={(e) =>
|
||||
setTelegramBotToken(e.target.value)
|
||||
}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<label style={labelStyle}>Chat ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramChatId}
|
||||
onChange={(e) =>
|
||||
setTelegramChatId(e.target.value)
|
||||
}
|
||||
placeholder="-1001234567890"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={helpLinkStyle}
|
||||
>
|
||||
How to create a Telegram bot & get your Chat ID
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||
Email
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<label style={labelStyle}>Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
color: "#808080",
|
||||
marginBottom: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Alert notifications and digests will be sent to
|
||||
this address
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleSetupEmail}
|
||||
disabled={settingUpEmail || !email}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.85rem",
|
||||
backgroundColor:
|
||||
settingUpEmail || !email
|
||||
? "#333"
|
||||
: "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor:
|
||||
settingUpEmail || !email
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{settingUpEmail
|
||||
? "Setting up..."
|
||||
: "Verify Email"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSendDigest}
|
||||
disabled={sendingDigest || !email}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.85rem",
|
||||
backgroundColor:
|
||||
sendingDigest || !email
|
||||
? "#333"
|
||||
: "#6c757d",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor:
|
||||
sendingDigest || !email
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{sendingDigest
|
||||
? "Sending..."
|
||||
: "Send Digest Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||
Discord
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<label style={labelStyle}>
|
||||
Discord Webhook URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={discordWebhookUrl}
|
||||
onChange={(e) =>
|
||||
setDiscordWebhookUrl(e.target.value)
|
||||
}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={helpLinkStyle}
|
||||
>
|
||||
How to get a Discord webhook URL
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div style={sectionStyle}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||
Slack
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: "0.5rem" }}>
|
||||
<label style={labelStyle}>
|
||||
Slack Webhook URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slackWebhookUrl}
|
||||
onChange={(e) =>
|
||||
setSlackWebhookUrl(e.target.value)
|
||||
}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={helpLinkStyle}
|
||||
>
|
||||
How to set up Slack Incoming Webhooks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Save & Test buttons at the bottom */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
marginTop: "1rem",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleSaveNotificationConfig}
|
||||
disabled={notificationLoading}
|
||||
style={{
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: notificationLoading
|
||||
? "#333"
|
||||
: "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: notificationLoading
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{notificationLoading
|
||||
? "Saving..."
|
||||
: "Save Settings"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testingChannels}
|
||||
style={{
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: testingChannels
|
||||
? "#333"
|
||||
: "#28a745",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: testingChannels
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{testingChannels
|
||||
? "Testing..."
|
||||
: "Test All Channels"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAlertType(type) {
|
||||
const labels = {
|
||||
incoming_tx: "Incoming transaction",
|
||||
outgoing_tx: "Outgoing transaction",
|
||||
large_transfer: "Large transfer",
|
||||
balance_below: "Balance below threshold",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Login Page
|
||||
*
|
||||
* Allows existing users to sign in with email and password
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
await login(email, password);
|
||||
navigate("/addresses"); // Redirect to main app
|
||||
} catch (err) {
|
||||
setError("Failed to log in: " + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "400px",
|
||||
margin: "4rem auto",
|
||||
padding: "2rem",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||
Koin Ping - Login
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#ff000020",
|
||||
border: "1px solid #ff0000",
|
||||
borderRadius: "4px",
|
||||
color: "#ff6666",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: loading ? "#333" : "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Logging in..." : "Log In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||
<p style={{ color: "#b3b3b3" }}>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||
>
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +1,5 @@
|
||||
/**
|
||||
* Signup Page
|
||||
*
|
||||
* Allows new users to create an account with email and password
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function Signup() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!email || !password || !confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
await signup(email, password);
|
||||
navigate("/addresses"); // Auto-login and redirect
|
||||
} catch (err) {
|
||||
// Firebase-specific error messages
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. Try logging in instead.");
|
||||
} else if (err.code === "auth/invalid-email") {
|
||||
setError("Invalid email address");
|
||||
} else if (err.code === "auth/weak-password") {
|
||||
setError("Password is too weak");
|
||||
} else {
|
||||
setError("Failed to create account: " + err.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "400px",
|
||||
margin: "4rem auto",
|
||||
padding: "2rem",
|
||||
border: "1px solid #333",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
||||
Koin Ping - Sign Up
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "#ff000020",
|
||||
border: "1px solid #ff0000",
|
||||
borderRadius: "4px",
|
||||
color: "#ff6666",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: "#242424",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: "white",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem",
|
||||
fontSize: "1rem",
|
||||
backgroundColor: loading ? "#333" : "#0066cc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "Creating account..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||
<p style={{ color: "#b3b3b3" }}>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
||||
>
|
||||
Log in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Navigate to="/subscribe" replace />;
|
||||
}
|
||||
|
||||
30
frontend/src/pages/addresses/Addresses.css
Normal file
30
frontend/src/pages/addresses/Addresses.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.address__edit-input {
|
||||
background: var(--color-border);
|
||||
border: 1px solid #666;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.address__edit-link {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6699cc;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.address__remove {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-danger-border);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
159
frontend/src/pages/addresses/Addresses.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AddressForm from "../../components/AddressForm";
|
||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
|
||||
import "./Addresses.css";
|
||||
|
||||
export default function Addresses() {
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAddresses();
|
||||
setAddresses(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch addresses:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAddresses();
|
||||
}, []);
|
||||
|
||||
async function handleAddressSubmit(data) {
|
||||
try {
|
||||
const newAddress = await createAddress(data);
|
||||
setAddresses((prev) => [...prev, newAddress]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id, label) {
|
||||
const displayName = label || "this address";
|
||||
if (!window.confirm(`Remove "${displayName}"? This will also delete all associated alert rules.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAddress(id);
|
||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditStart(addr) {
|
||||
setEditingId(addr.id);
|
||||
setEditLabel(addr.label ?? "");
|
||||
}
|
||||
|
||||
async function handleEditSave(id) {
|
||||
try {
|
||||
const updated = await updateAddress(id, { label: editLabel || null });
|
||||
setAddresses((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update address:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
setEditingId(null);
|
||||
setEditLabel("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Add Addresses to Track</h1>
|
||||
|
||||
<div className="mb-xl">
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Existing Tracked Addresses</h2>
|
||||
{loading && <p>Loading addresses...</p>}
|
||||
{error && <p className="text-error">Error: {error}</p>}
|
||||
{!loading && !error && addresses.length === 0 && (
|
||||
<p className="text-dimmed">
|
||||
No addresses tracked yet. Add one above to get started.
|
||||
</p>
|
||||
)}
|
||||
{addresses.length > 0 && (
|
||||
<ul className="list-unstyled">
|
||||
{addresses.map((addr, index) => (
|
||||
<li key={addr.id || index} className="list-item--card">
|
||||
<div className="flex flex--between flex--center">
|
||||
<div>
|
||||
{editingId === addr.id ? (
|
||||
<div className="flex flex--center gap-sm mb-sm">
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
className="address__edit-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleEditSave(addr.id);
|
||||
if (e.key === "Escape") handleEditCancel();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEditSave(addr.id)}
|
||||
className="btn btn--primary btn--sm"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEditCancel}
|
||||
className="btn btn--ghost btn--sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex--center gap-sm mb-sm">
|
||||
<span className="text-bold">
|
||||
{addr.label || "Unlabeled"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditStart(addr)}
|
||||
className="address__edit-link"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-mono text-sm text-muted">
|
||||
{addr.address}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(addr.id, addr.label)}
|
||||
className="address__remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/pages/alertHistory/AlertHistory.css
Normal file
7
frontend/src/pages/alertHistory/AlertHistory.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.alert-history__item {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
67
frontend/src/pages/alertHistory/AlertHistory.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getAlertEvents } from "../../api/alertEvents";
|
||||
import "./AlertHistory.css";
|
||||
|
||||
export default function AlertHistory() {
|
||||
const [alertEvents, setAlertEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAlertEvents() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAlertEvents();
|
||||
setAlertEvents(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch alert events:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAlertEvents();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="page">Loading...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="page text-error">Error: {error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Recent Alert Events</h1>
|
||||
|
||||
{alertEvents.length === 0 ? (
|
||||
<p className="text-dimmed">No alerts yet</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{alertEvents.map((event) => (
|
||||
<li key={event.id} className="alert-history__item">
|
||||
<div className="mb-sm">{event.message}</div>
|
||||
{event.address_label && (
|
||||
<div className="text-sm text-dimmed">
|
||||
Address: {event.address_label}
|
||||
</div>
|
||||
)}
|
||||
<small className="text-muted">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
63
frontend/src/pages/alerts/Alerts.css
Normal file
63
frontend/src/pages/alerts/Alerts.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.alerts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.alerts__address-info {
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.alerts__toggle-panel {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.alerts__toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.alerts__toggle-checkbox {
|
||||
margin-right: 0.5rem;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.alerts__toggle-hint {
|
||||
font-size: 0.978rem;
|
||||
color: var(--color-text-dimmed);
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 26px;
|
||||
}
|
||||
|
||||
.alerts__rule {
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.alerts__rule--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.alerts__email-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alerts__save-test {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
522
frontend/src/pages/alerts/Alerts.jsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AlertForm from "../../components/AlertForm";
|
||||
import Button from "../../components/Button";
|
||||
import Input from "../../components/Input";
|
||||
import { getAddresses } from "../../api/addresses";
|
||||
import {
|
||||
getAlerts,
|
||||
createAlert,
|
||||
updateAlertStatus,
|
||||
deleteAlert,
|
||||
} from "../../api/alerts";
|
||||
import {
|
||||
getNotificationConfig,
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
setupEmail,
|
||||
sendEmailDigest,
|
||||
} from "../../api/notificationConfig";
|
||||
import "./Alerts.css";
|
||||
|
||||
export default function Alerts() {
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
||||
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||
const [telegramChatId, setTelegramChatId] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||
const [notificationError, setNotificationError] = useState(null);
|
||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
||||
const [testingChannels, setTestingChannels] = useState(false);
|
||||
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||
const [sendingDigest, setSendingDigest] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const addressData = await getAddresses();
|
||||
setAddresses(addressData);
|
||||
if (addressData.length > 0) {
|
||||
setSelectedAddressId(addressData[0].id);
|
||||
}
|
||||
|
||||
const configData = await getNotificationConfig();
|
||||
setNotificationEnabled(
|
||||
configData.notification_enabled !== false,
|
||||
);
|
||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
||||
setTelegramBotToken(configData.telegram_bot_token || "");
|
||||
setTelegramChatId(configData.telegram_chat_id || "");
|
||||
setEmail(configData.email || "");
|
||||
setSlackWebhookUrl(configData.slack_webhook_url || "");
|
||||
|
||||
const hasSaved =
|
||||
!!configData.discord_webhook_url ||
|
||||
!!configData.telegram_bot_token ||
|
||||
!!configData.telegram_chat_id ||
|
||||
!!configData.email ||
|
||||
!!configData.slack_webhook_url;
|
||||
setHasExistingConfig(hasSaved);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch data:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAddressId) {
|
||||
setAlerts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchAlerts() {
|
||||
try {
|
||||
const data = await getAlerts(selectedAddressId);
|
||||
setAlerts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to fetch alerts:", err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAlerts();
|
||||
}, [selectedAddressId]);
|
||||
|
||||
async function handleAlertSubmit(data) {
|
||||
if (!selectedAddressId) return;
|
||||
|
||||
try {
|
||||
const newAlert = await createAlert(selectedAddressId, data);
|
||||
setAlerts((prev) => [...prev, newAlert]);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAlert(alertId, currentStatus) {
|
||||
try {
|
||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||
setAlerts((prev) =>
|
||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
||||
);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to update alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAlert(alertId) {
|
||||
try {
|
||||
await deleteAlert(alertId);
|
||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete alert:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveNotificationConfig() {
|
||||
if (hasExistingConfig) {
|
||||
const confirmed = window.confirm(
|
||||
"This will overwrite your previously saved notification settings. Continue?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNotificationLoading(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const config = {
|
||||
notification_enabled: notificationEnabled,
|
||||
discord_webhook_url: discordWebhookUrl || null,
|
||||
telegram_bot_token: telegramBotToken || null,
|
||||
telegram_chat_id: telegramChatId || null,
|
||||
email: email || null,
|
||||
slack_webhook_url: slackWebhookUrl || null,
|
||||
};
|
||||
|
||||
await updateNotificationConfig(config);
|
||||
setHasExistingConfig(true);
|
||||
setNotificationSuccess("Notification settings saved!");
|
||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
console.error("Failed to save notification config:", err);
|
||||
} finally {
|
||||
setNotificationLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestChannels() {
|
||||
try {
|
||||
setTestingChannels(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const data = await testNotificationChannels();
|
||||
const results = data.results || [];
|
||||
|
||||
const failed = results.filter((r) => !r.success);
|
||||
const succeeded = results.filter((r) => r.success);
|
||||
|
||||
if (failed.length === 0 && succeeded.length > 0) {
|
||||
setNotificationSuccess(
|
||||
`Test sent to: ${succeeded.map((r) => r.channel).join(", ")}`,
|
||||
);
|
||||
} else if (failed.length > 0 && succeeded.length > 0) {
|
||||
setNotificationSuccess(
|
||||
`Sent: ${succeeded.map((r) => r.channel).join(", ")}. Failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||
);
|
||||
} else if (failed.length > 0) {
|
||||
setNotificationError(
|
||||
`Test failed: ${failed.map((r) => `${r.channel} (${r.error})`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setNotificationSuccess(null);
|
||||
setNotificationError(null);
|
||||
}, 6000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setTestingChannels(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetupEmail() {
|
||||
try {
|
||||
setSettingUpEmail(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
await handleSaveNotificationConfig();
|
||||
|
||||
const result = await setupEmail();
|
||||
setNotificationSuccess(
|
||||
result.message || "Confirmation email sent!",
|
||||
);
|
||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setSettingUpEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendDigest() {
|
||||
try {
|
||||
setSendingDigest(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
const result = await sendEmailDigest();
|
||||
setNotificationSuccess(result.message || "Digest email sent!");
|
||||
setTimeout(() => setNotificationSuccess(null), 5000);
|
||||
} catch (err) {
|
||||
setNotificationError(err.message);
|
||||
} finally {
|
||||
setSendingDigest(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAddress = addresses.find((a) => a.id === selectedAddressId);
|
||||
|
||||
if (loading) {
|
||||
return <div className="page">Loading addresses...</div>;
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<div className="page">
|
||||
<p>No addresses tracked yet. Add an address first to create alerts.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page page--wide">
|
||||
<h1 className="mb-xl">Alert Rules & Notifications</h1>
|
||||
|
||||
<div className="alerts-grid">
|
||||
{/* LEFT COLUMN: Alert Rules */}
|
||||
<div>
|
||||
<h2 className="mt-0">Alert Rules</h2>
|
||||
|
||||
<div className="mb-xl">
|
||||
<label className="form-label mb-sm">
|
||||
<strong>Select Address:</strong>
|
||||
</label>
|
||||
<select
|
||||
value={selectedAddressId || ""}
|
||||
onChange={(e) =>
|
||||
setSelectedAddressId(Number(e.target.value))
|
||||
}
|
||||
className="form-select"
|
||||
>
|
||||
{addresses.map((addr) => (
|
||||
<option key={addr.id} value={addr.id}>
|
||||
{addr.label || "Unlabeled"} - {addr.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedAddress && (
|
||||
<>
|
||||
<div className="alerts__address-info">
|
||||
<div className="text-sm text-muted">
|
||||
Managing alerts for:
|
||||
</div>
|
||||
<div className="text-bold">
|
||||
{selectedAddress.label || "Unlabeled"}
|
||||
</div>
|
||||
<div className="text-mono text-sm text-muted">
|
||||
{selectedAddress.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-xl">
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
{error && (
|
||||
<p className="text-error">Error: {error}</p>
|
||||
)}
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-dimmed">
|
||||
No alert rules defined yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{alerts.map((alert) => (
|
||||
<li
|
||||
key={alert.id}
|
||||
className={`alerts__rule ${!alert.enabled ? "alerts__rule--disabled" : ""}`}
|
||||
>
|
||||
<div className="flex flex--between">
|
||||
<div>
|
||||
<div className="text-bold mb-sm">
|
||||
{formatAlertType(alert.type)}
|
||||
</div>
|
||||
{alert.threshold && (
|
||||
<div className="text-sm text-muted">
|
||||
Threshold: {alert.threshold} ETH
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-dimmed">
|
||||
Status: {alert.enabled ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex--center gap-sm">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleToggleAlert(alert.id, alert.enabled)
|
||||
}
|
||||
>
|
||||
{alert.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteAlert(alert.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Notification Settings */}
|
||||
<div>
|
||||
<h2 className="mt-0">Notification Settings</h2>
|
||||
|
||||
{notificationSuccess && (
|
||||
<div className="alert alert--success">{notificationSuccess}</div>
|
||||
)}
|
||||
|
||||
{notificationError && (
|
||||
<div className="alert alert--error">{notificationError}</div>
|
||||
)}
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="alerts__toggle-panel">
|
||||
<label className="alerts__toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationEnabled}
|
||||
onChange={(e) =>
|
||||
setNotificationEnabled(e.target.checked)
|
||||
}
|
||||
className="alerts__toggle-checkbox"
|
||||
/>
|
||||
<span className="text-bold">Enable Notifications</span>
|
||||
</label>
|
||||
<div className="alerts__toggle-hint">
|
||||
Master switch for all notification channels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All channel settings -- hidden when master toggle is off */}
|
||||
{notificationEnabled && (
|
||||
<>
|
||||
{/* Telegram */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||
<Input
|
||||
label="Bot Token"
|
||||
value={telegramBotToken}
|
||||
onChange={setTelegramBotToken}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||
/>
|
||||
<Input
|
||||
label="Chat ID"
|
||||
value={telegramChatId}
|
||||
onChange={setTelegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
<a
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to create a Telegram bot & get your Chat ID
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Email</h3>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<div className="help-link text-dimmed mb-md">
|
||||
Alert notifications and digests will be sent to this address
|
||||
</div>
|
||||
<div className="alerts__email-buttons">
|
||||
<Button
|
||||
onClick={handleSetupEmail}
|
||||
disabled={settingUpEmail || !email}
|
||||
className="btn--sm"
|
||||
>
|
||||
{settingUpEmail ? "Setting up..." : "Verify Email"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSendDigest}
|
||||
disabled={sendingDigest || !email}
|
||||
variant="secondary"
|
||||
className="btn--sm"
|
||||
>
|
||||
{sendingDigest ? "Sending..." : "Send Digest Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Discord</h3>
|
||||
<Input
|
||||
label="Discord Webhook URL"
|
||||
value={discordWebhookUrl}
|
||||
onChange={setDiscordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to get a Discord webhook URL
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="section">
|
||||
<h3 className="mt-0 mb-md">Slack</h3>
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
value={slackWebhookUrl}
|
||||
onChange={setSlackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
How to set up Slack Incoming Webhooks
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Save & Test buttons */}
|
||||
<div className="alerts__save-test">
|
||||
<Button
|
||||
onClick={handleSaveNotificationConfig}
|
||||
disabled={notificationLoading}
|
||||
className="btn--lg"
|
||||
>
|
||||
{notificationLoading ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testingChannels}
|
||||
variant="success"
|
||||
className="btn--lg"
|
||||
>
|
||||
{testingChannels ? "Testing..." : "Test All Channels"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAlertType(type) {
|
||||
const labels = {
|
||||
incoming_tx: "Incoming transaction",
|
||||
outgoing_tx: "Outgoing transaction",
|
||||
large_transfer: "Large transfer",
|
||||
balance_below: "Balance below threshold",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
64
frontend/src/pages/login/Login.css
Normal file
64
frontend/src/pages/login/Login.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background-image: url(/ping.png);
|
||||
background-size: 67%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
max-width: 400px;
|
||||
margin: 20px auto;
|
||||
padding: 2rem;
|
||||
padding-top: 8rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.login-heading {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 16px;
|
||||
;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-signup-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-form-visible {
|
||||
animation: fadeIn 2s ease-in-out forwards;
|
||||
}
|
||||
83
frontend/src/pages/login/Login.jsx
Normal file
83
frontend/src/pages/login/Login.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import Input from "../../components/Input";
|
||||
import "./Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError("");
|
||||
setLoading(true);
|
||||
await login(email, password);
|
||||
navigate("/addresses");
|
||||
} catch (err) {
|
||||
setError("Failed to log in: " + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card" onMouseEnter={() => setIsVisible(true)}>
|
||||
<h1 className="login-heading">
|
||||
<span className="login-brand">Koin Ping</span> - Login
|
||||
</h1>
|
||||
|
||||
<div className={isVisible ? "login-form-visible" : "login-form-hidden"}>
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
disabled={loading}
|
||||
required
|
||||
className="form-field--last"
|
||||
/>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn--primary login-button">
|
||||
{loading ? "Logging in..." : "Log In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p className="text-muted">
|
||||
Don't have an account?{" "}
|
||||
<Link to="/signup" className="login-signup-link">
|
||||
Sign up here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
frontend/src/pages/subscribe/subscribe.css
Normal file
238
frontend/src/pages/subscribe/subscribe.css
Normal file
@@ -0,0 +1,238 @@
|
||||
.subscribe {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.subscribe__container {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.subscribe__title {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subscribe__card {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.subscribe__login-link {
|
||||
text-align: center;
|
||||
margin-top: 1.25rem;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.subscribe__login-link a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.progress-bar__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-bar__connector {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.progress-bar__connector--active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.progress-bar__connector--inactive {
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.progress-bar__dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
margin: 0 auto 4px;
|
||||
}
|
||||
|
||||
.progress-bar__dot--done,
|
||||
.progress-bar__dot--active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.progress-bar__dot--active {
|
||||
border: 2px solid #4499ff;
|
||||
}
|
||||
|
||||
.progress-bar__dot--pending {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.progress-bar__label {
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-bar__label--active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.progress-bar__label--inactive {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Step 5 summary */
|
||||
.subscribe__summary {
|
||||
background-color: #1e2e1e;
|
||||
border: 1px solid #2d5a2d;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.subscribe__summary-title {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #90ee90;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subscribe__summary-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--color-text-label);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Checkbox rows */
|
||||
.checkbox-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-row__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.checkbox-row__input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.checkbox-row__nested {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Footer navigation */
|
||||
.subscribe__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* Test results */
|
||||
.test-result {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.test-result--success {
|
||||
color: #90ee90;
|
||||
}
|
||||
|
||||
.test-result--failure {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Step subtitle */
|
||||
.subscribe__subtitle {
|
||||
color: #aaa;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Subscribe card (Step 2) */
|
||||
.subscribe-card {
|
||||
background-color: var(--color-bg-card, #1a1a2e);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-card__price {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.subscribe-card__amount {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subscribe-card__period {
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.subscribe-card__features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.subscribe-card__features li {
|
||||
padding: 0.35rem 0;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.subscribe-card__features li::before {
|
||||
content: "\2713";
|
||||
color: var(--color-primary);
|
||||
margin-right: 0.6rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subscribe-card__commitment {
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
679
frontend/src/pages/subscribe/subscribe.jsx
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* Subscribe / Subscribe Wizard
|
||||
*
|
||||
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
||||
* After account creation, user is redirected to Stripe Checkout for payment.
|
||||
* On successful payment, they return here at step 2 (Add Wallet).
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { createAddress, getAddresses } from "../../api/addresses";
|
||||
import { createAlert } from "../../api/alerts";
|
||||
import {
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
} from "../../api/notificationConfig";
|
||||
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
|
||||
import Input from "../../components/Input";
|
||||
import Button from "../../components/Button";
|
||||
import "./Subscribe.css";
|
||||
|
||||
const STEPS = [
|
||||
"Create Account",
|
||||
"Add Wallet",
|
||||
"Alert Rules",
|
||||
"Notifications",
|
||||
"Done",
|
||||
];
|
||||
|
||||
export default function Subscribe() {
|
||||
const { currentUser, signup } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [skipWarning, setSkipWarning] = useState("");
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
const [data, setData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
walletAddress: "",
|
||||
walletLabel: "",
|
||||
createdAddressId: null,
|
||||
alertIncomingTx: false,
|
||||
alertOutgoingTx: false,
|
||||
alertLargeTransfer: false,
|
||||
largeTransferThreshold: "",
|
||||
alertBalanceBelow: false,
|
||||
balanceBelowThreshold: "",
|
||||
discordWebhookUrl: "",
|
||||
slackWebhookUrl: "",
|
||||
notificationEmail: "",
|
||||
alertsCreated: [],
|
||||
notificationConfigured: false,
|
||||
});
|
||||
|
||||
function set(field, value) {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
getAddresses()
|
||||
.then((addresses) => {
|
||||
if (addresses.length > 0) {
|
||||
navigate("/addresses", { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
// Handle Stripe redirect back from checkout
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
const payment = searchParams.get("payment");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
if (payment === "success" && sessionId) {
|
||||
setSearchParams({}, { replace: true });
|
||||
setLoading(true);
|
||||
verifyCheckoutSession(sessionId)
|
||||
.then(() => {
|
||||
setStep(2);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError("Payment verification failed: " + err.message);
|
||||
setStep(1);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else if (payment === "cancelled") {
|
||||
setSearchParams({}, { replace: true });
|
||||
setStep(1);
|
||||
setError("Payment was cancelled. Please try again.");
|
||||
}
|
||||
}, [currentUser, searchParams, setSearchParams]);
|
||||
|
||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStep1() {
|
||||
setError("");
|
||||
if (!data.email || !data.password || !data.confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
return;
|
||||
}
|
||||
if (data.password !== data.confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (data.password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!currentUser) {
|
||||
await signup(data.email, data.password);
|
||||
}
|
||||
const status = await getSubscriptionStatus();
|
||||
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
const { url } = await createCheckoutSession();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
setError("Email already in use. Try logging in instead.");
|
||||
} else if (err.code === "auth/invalid-email") {
|
||||
setError("Invalid email address");
|
||||
} else if (err.code === "auth/weak-password") {
|
||||
setError("Password is too weak");
|
||||
} else {
|
||||
setError("Failed to create account: " + err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
setError("");
|
||||
if (!data.walletAddress) {
|
||||
setError("Please enter a wallet address");
|
||||
return;
|
||||
}
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
|
||||
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const created = await createAddress({
|
||||
address: data.walletAddress,
|
||||
label: data.walletLabel || undefined,
|
||||
});
|
||||
set("createdAddressId", created.id);
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
setError("");
|
||||
const rules = [];
|
||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
|
||||
if (data.alertLargeTransfer) {
|
||||
if (!data.largeTransferThreshold) {
|
||||
setError("Please enter a threshold for large transfers");
|
||||
return;
|
||||
}
|
||||
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
|
||||
}
|
||||
if (data.alertBalanceBelow) {
|
||||
if (!data.balanceBelowThreshold) {
|
||||
setError("Please enter a threshold for balance below");
|
||||
return;
|
||||
}
|
||||
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
setStep(4);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const created = [];
|
||||
for (const rule of rules) {
|
||||
const result = await createAlert(data.createdAddressId, rule);
|
||||
created.push(result);
|
||||
}
|
||||
set("alertsCreated", created);
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep4() {
|
||||
setError("");
|
||||
const hasAny =
|
||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||
if (!hasAny) {
|
||||
setStep(5);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateNotificationConfig({
|
||||
notification_enabled: true,
|
||||
discord_webhook_url: data.discordWebhookUrl || undefined,
|
||||
slack_webhook_url: data.slackWebhookUrl || undefined,
|
||||
email: data.notificationEmail || undefined,
|
||||
});
|
||||
set("notificationConfigured", true);
|
||||
setStep(5);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestChannels() {
|
||||
setTestLoading(true);
|
||||
setTestResults(null);
|
||||
try {
|
||||
const results = await testNotificationChannels();
|
||||
setTestResults(results);
|
||||
} catch (err) {
|
||||
setTestResults({ error: err.message });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress bar ──────────────────────────────────────────────────────────
|
||||
|
||||
function ProgressBar() {
|
||||
return (
|
||||
<div className="progress-bar">
|
||||
{STEPS.map((label, i) => {
|
||||
const stepNum = i + 1;
|
||||
const done = step > stepNum;
|
||||
const active = step === stepNum;
|
||||
const dotClass = done
|
||||
? "progress-bar__dot--done"
|
||||
: active
|
||||
? "progress-bar__dot--active"
|
||||
: "progress-bar__dot--pending";
|
||||
|
||||
return (
|
||||
<div key={label} className="progress-bar__step">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={`progress-bar__connector ${done || active
|
||||
? "progress-bar__connector--active"
|
||||
: "progress-bar__connector--inactive"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className={`progress-bar__dot ${dotClass}`}>
|
||||
{done ? "\u2713" : stepNum}
|
||||
</div>
|
||||
<div
|
||||
className={`progress-bar__label ${active
|
||||
? "progress-bar__label--active"
|
||||
: "progress-bar__label--inactive"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step content ──────────────────────────────────────────────────────────
|
||||
|
||||
function Step1() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-lg">Create your account</h2>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(v) => set("email", v)}
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={(v) => set("password", v)}
|
||||
disabled={loading}
|
||||
placeholder="At least 6 characters"
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={data.confirmPassword}
|
||||
onChange={(v) => set("confirmPassword", v)}
|
||||
disabled={loading}
|
||||
placeholder="Repeat your password"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Add a wallet address</h2>
|
||||
<p className="subscribe__subtitle">
|
||||
Enter the Ethereum address you want to monitor.
|
||||
</p>
|
||||
<Input
|
||||
label="ETH Address"
|
||||
value={data.walletAddress}
|
||||
onChange={(v) => set("walletAddress", v)}
|
||||
disabled={loading}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
<Input
|
||||
label="Label (optional)"
|
||||
value={data.walletLabel}
|
||||
onChange={(v) => set("walletLabel", v)}
|
||||
disabled={loading}
|
||||
placeholder="e.g. My main wallet"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step3() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Configure alert rules</h2>
|
||||
<p className="subscribe__subtitle">
|
||||
Choose which events trigger notifications. You can change these later.
|
||||
</p>
|
||||
|
||||
<CheckboxRow
|
||||
checked={data.alertIncomingTx}
|
||||
onChange={(v) => set("alertIncomingTx", v)}
|
||||
label="Incoming transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertOutgoingTx}
|
||||
onChange={(v) => set("alertOutgoingTx", v)}
|
||||
label="Outgoing transaction"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={data.alertLargeTransfer}
|
||||
onChange={(v) => set("alertLargeTransfer", v)}
|
||||
label="Large transfer"
|
||||
>
|
||||
{data.alertLargeTransfer && (
|
||||
<div className="checkbox-row__nested">
|
||||
<Input
|
||||
type="number"
|
||||
label=""
|
||||
value={data.largeTransferThreshold}
|
||||
onChange={(v) => set("largeTransferThreshold", v)}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
<CheckboxRow
|
||||
checked={data.alertBalanceBelow}
|
||||
onChange={(v) => set("alertBalanceBelow", v)}
|
||||
label="Balance below"
|
||||
>
|
||||
{data.alertBalanceBelow && (
|
||||
<div className="checkbox-row__nested">
|
||||
<Input
|
||||
type="number"
|
||||
label=""
|
||||
value={data.balanceBelowThreshold}
|
||||
onChange={(v) => set("balanceBelowThreshold", v)}
|
||||
placeholder="Threshold (ETH)"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step4() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Set up notifications</h2>
|
||||
<p className="subscribe__subtitle">
|
||||
Add at least one channel so you receive alerts. All fields are optional.
|
||||
</p>
|
||||
|
||||
<div className="mb-md">
|
||||
<label className="form-label">
|
||||
Discord Webhook URL{" "}
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<Input
|
||||
label=""
|
||||
type="url"
|
||||
value={data.discordWebhookUrl}
|
||||
onChange={(v) => set("discordWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-md">
|
||||
<label className="form-label">
|
||||
Slack Webhook URL{" "}
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="help-link"
|
||||
>
|
||||
(how to get one)
|
||||
</a>
|
||||
</label>
|
||||
<Input
|
||||
label=""
|
||||
type="url"
|
||||
value={data.slackWebhookUrl}
|
||||
onChange={(v) => set("slackWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email address for alerts"
|
||||
type="email"
|
||||
value={data.notificationEmail}
|
||||
onChange={(v) => set("notificationEmail", v)}
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
className="form-field--last"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step5() {
|
||||
const alertCount = data.alertsCreated.length;
|
||||
const hasNotif = data.notificationConfigured;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-md">You're all set!</h2>
|
||||
|
||||
<div className="subscribe__summary">
|
||||
<p className="subscribe__summary-title">Summary</p>
|
||||
<ul className="subscribe__summary-list">
|
||||
<li>
|
||||
Wallet address added:{" "}
|
||||
<span className="text-mono text-white-sm">
|
||||
{data.walletAddress}
|
||||
</span>
|
||||
{data.walletLabel && ` (${data.walletLabel})`}
|
||||
</li>
|
||||
<li>
|
||||
Alert rules configured:{" "}
|
||||
<span className="text-white">
|
||||
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Notification channels:{" "}
|
||||
<span className="text-white">
|
||||
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasNotif && (
|
||||
<div className="mb-lg">
|
||||
<Button
|
||||
onClick={handleTestChannels}
|
||||
disabled={testLoading}
|
||||
variant="ghost"
|
||||
>
|
||||
{testLoading ? "Testing..." : "Test All Channels"}
|
||||
</Button>
|
||||
|
||||
{testResults && (
|
||||
<div className="mt-md">
|
||||
{testResults.error ? (
|
||||
<p className="text-error">{testResults.error}</p>
|
||||
) : (
|
||||
<ul className="list-unstyled">
|
||||
{Object.entries(testResults).map(([channel, result]) => (
|
||||
<li
|
||||
key={channel}
|
||||
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
|
||||
>
|
||||
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
|
||||
{result.message || (result.success ? "OK" : "Failed")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
|
||||
Go to Dashboard →
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function CheckboxRow({ checked, onChange, label, children }) {
|
||||
return (
|
||||
<div className="checkbox-row">
|
||||
<label className="checkbox-row__label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="checkbox-row__input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||
|
||||
function Footer() {
|
||||
if (step === 5) return null;
|
||||
|
||||
const canSkip = step === 3 || step === 4;
|
||||
const canBack = step > 2;
|
||||
|
||||
async function handleNext() {
|
||||
setSkipWarning("");
|
||||
if (step === 1) await handleStep1();
|
||||
else if (step === 2) await handleStep2();
|
||||
else if (step === 3) await handleStep3();
|
||||
else if (step === 4) await handleStep4();
|
||||
}
|
||||
|
||||
function handleSkip() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s + 1);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
setError("");
|
||||
setSkipWarning("");
|
||||
setStep((s) => s - 1);
|
||||
}
|
||||
|
||||
const nextLabel = step === 1
|
||||
? "Create Account & Subscribe"
|
||||
: step === 4
|
||||
? "Finish"
|
||||
: "Next →";
|
||||
|
||||
return (
|
||||
<div className="subscribe__footer">
|
||||
<div>
|
||||
{canBack && (
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
variant="ghost"
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-md">
|
||||
{canSkip && (
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={loading}
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className="text-bold"
|
||||
>
|
||||
{loading ? "Please wait..." : nextLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepContent = {
|
||||
1: Step1(),
|
||||
2: Step2(),
|
||||
3: Step3(),
|
||||
4: Step4(),
|
||||
5: Step5(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="subscribe">
|
||||
<div className="subscribe__container">
|
||||
<h1 className="subscribe__title">Koin Ping</h1>
|
||||
|
||||
{ProgressBar()}
|
||||
|
||||
{error && (
|
||||
<div className="alert alert--error">{error}</div>
|
||||
)}
|
||||
|
||||
{skipWarning && (
|
||||
<div className="alert alert--warning">{skipWarning}</div>
|
||||
)}
|
||||
|
||||
<div className="subscribe__card">
|
||||
{stepContent[step]}
|
||||
{Footer()}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<p className="subscribe__login-link">
|
||||
Already have an account?{" "}
|
||||
<a href="/login">Log in here</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user