Compare commits
29 Commits
format-maj
...
migrate-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a08e8f25 | ||
|
|
8716be0201 | ||
|
|
647be874e5 | ||
|
|
c12c4fe742 | ||
|
|
5f5253d495 | ||
|
|
bbe2374703 | ||
|
|
205317c920 | ||
|
|
25850825b4 | ||
|
|
f01115de86 | ||
|
|
6735000050 | ||
|
|
42a64fc043 | ||
|
|
9935817fa8 | ||
|
|
8d5716bdb7 | ||
|
|
aafe96d9a1 | ||
|
|
93168209b5 | ||
|
|
6840af1a47 | ||
|
|
7c36f3c214 | ||
|
|
c707b82f36 | ||
|
|
37ff644aa7 | ||
|
|
c80ed89c0a | ||
|
|
6590cbf4be | ||
|
|
61a8c5fe8b | ||
|
|
c79136a06e | ||
|
|
a5590a438f | ||
|
|
1c196cae9f | ||
|
|
c494bf5f53 | ||
|
|
d07dc971fe | ||
|
|
112c9d0627 | ||
|
|
b1e63a24c7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ Prompts/
|
|||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
.claude
|
||||||
|
|||||||
80
README.md
80
README.md
@@ -1,9 +1,9 @@
|
|||||||
# Koin Ping
|
# Koin Ping
|
||||||
|
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
|
||||||
|
|
||||||
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
|
# Overview
|
||||||
that polls Ethereum addresses for on-chain activity and delivers real-time
|
|
||||||
alerts to users via a Go REST API backend and a React single-page application
|
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.
|
||||||
frontend.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -45,16 +45,16 @@ cd frontend && npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
The API listens on `http://localhost:3001` and the frontend on
|
The API listens on `http://localhost:3001` and the frontend on
|
||||||
`http://localhost:3000` by default.
|
`http://localhost:3000` by default for development.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Crypto users who hold or actively monitor addresses need a lightweight, reliable
|
Crypto users may actively monitor addresses with webhooks integrating popular
|
||||||
way to know when on-chain activity occurs without polling block explorers
|
messaging platforms: Discord, Slack, Telegram.
|
||||||
manually. Koin Ping fills that gap: it watches a set of Ethereum addresses,
|
This lightweight, reliable framework makes instant awareness of on-chain
|
||||||
evaluates configurable alert rules (incoming transactions, outgoing
|
activity trivial, without polling block explorers manually. Koin Ping watches
|
||||||
transactions, large transfers, balance thresholds), and notifies the user
|
addresses, evaluates configurable alert rules (incoming transactions, outgoing
|
||||||
through Discord webhooks.
|
transactions, "large" transfers, balance thresholds), and sends notifications.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -99,19 +99,51 @@ to the database, and dispatches Discord notifications.
|
|||||||
Firebase, communicates with the API via fetch, and renders the address/alert
|
Firebase, communicates with the API via fetch, and renders the address/alert
|
||||||
management UI.
|
management UI.
|
||||||
|
|
||||||
## TODO
|
## Setting Up Your Alert Platforms
|
||||||
|
|
||||||
- [ ] Switch frontend package manager from npm to yarn (per repo policy)
|
Koin Ping can send real-time alerts to **Telegram**, **Discord**, **Slack**, and **Email**. Each channel is configured per-user through the **Notification Settings** panel on the Alerts page.
|
||||||
- [ ] Rename `go.mod` module path from `github.com/kjannette/koin-ping` to
|
|
||||||
`sneak.berlin/go/koin-ping` (per repo policy)
|
Below are step-by-step guides for setting up each platform.
|
||||||
- [ ] Move database migrations to `internal/db/migrations/` and embed them in
|
|
||||||
the binary
|
---
|
||||||
- [ ] Pin all Docker base images by `@sha256` in Dockerfile
|
|
||||||
- [ ] Install `golangci-lint` locally and pass `make lint`
|
### Telegram
|
||||||
- [ ] Add vitest unit tests for the React frontend
|
|
||||||
- [ ] Add a `make db-reset` / `make migrate` target for schema management
|
To receive alerts via Telegram, you need to create a bot and get your chat ID.
|
||||||
- [ ] Set HTTP server read/write timeouts (currently unbounded)
|
|
||||||
- [ ] Replace `log.Printf` calls with structured `log/slog`
|
#### 1. Create a Telegram Bot
|
||||||
|
|
||||||
|
1. Open Telegram and search for **@BotFather** (look for the blue verified checkmark).
|
||||||
|
2. Open the conversation with BotFather and send: `/newbot`
|
||||||
|
3. BotFather will ask for a **display name** — enter something like `Koin Ping Alerts`.
|
||||||
|
4. BotFather will ask for a **username** — it must end in `bot`, e.g. `MyKoinPingBot`.
|
||||||
|
5. BotFather will reply with your **Bot Token** — a string that looks like `123456789:ABCdefGHIjklMNOpqrSTUvwxYZ`. Copy it.
|
||||||
|
|
||||||
|
#### 2. Get Your Chat ID
|
||||||
|
|
||||||
|
1. In Telegram, search for the bot username you just created and open the chat.
|
||||||
|
2. Tap **Start** or send any message (e.g. `hello`).
|
||||||
|
3. Open the following URL in your browser, replacing `YOUR_BOT_TOKEN` with the token from step 1:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### 3. Save in Koin Ping
|
||||||
|
|
||||||
|
1. Go to the **Alerts** page in Koin Ping.
|
||||||
|
2. In the **Notification Settings** panel, find the **Telegram** section.
|
||||||
|
3. Paste your **Bot Token** and **Chat ID** into the corresponding fields.
|
||||||
|
4. Click **Save Settings**.
|
||||||
|
5. Click **Test All Channels** to verify — you should receive a test message from your bot in Telegram.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Guides for Discord, Slack, and Email coming soon.*
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -120,3 +152,5 @@ MIT. See [LICENSE](LICENSE).
|
|||||||
## Author
|
## Author
|
||||||
|
|
||||||
Steven Jannette
|
Steven Jannette
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ POLL_INTERVAL_MS=60000
|
|||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
FIREBASE_PROJECT_ID=koin-ping
|
FIREBASE_PROJECT_ID=koin-ping
|
||||||
|
|
||||||
|
# Email notifications (Resend — https://resend.com)
|
||||||
|
# RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||||
|
# EMAIL_FROM=Koin Ping <alerts@yourdomain.com>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
Run Backend:
|
|
||||||
|
|
||||||
cd /Users/kjannette/workspace/koin_ping/backend-go go run ./cmd/api
|
From the backend-go directory, you have a few options:
|
||||||
|
|
||||||
|
Option 1: Single command (both API + poller)
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
||||||
|
|
||||||
|
Option 2: Two separate terminals
|
||||||
|
Terminal 1 (API server):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
||||||
|
Terminal 2 (Poller):
|
||||||
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
|
||||||
BIN
backend-go/api
Executable file
BIN
backend-go/api
Executable file
Binary file not shown.
@@ -14,12 +14,13 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
"github.com/kjannette/koin-ping/backend-go/internal/handlers"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// serverReadTimeoutSeconds is the maximum duration to read a request.
|
// max duration to read a request.
|
||||||
serverReadTimeoutSeconds = 5
|
serverReadTimeoutSeconds = 5
|
||||||
// serverWriteTimeoutSeconds is the maximum duration to write a response.
|
// maxiduration to write a response.
|
||||||
serverWriteTimeoutSeconds = 10
|
serverWriteTimeoutSeconds = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,19 +47,26 @@ func main() {
|
|||||||
addressModel := models.NewAddressModel(pool)
|
addressModel := models.NewAddressModel(pool)
|
||||||
alertRuleModel := models.NewAlertRuleModel(pool)
|
alertRuleModel := models.NewAlertRuleModel(pool)
|
||||||
alertEventModel := models.NewAlertEventModel(pool)
|
alertEventModel := models.NewAlertEventModel(pool)
|
||||||
|
checkpointModel := models.NewCheckpointModel(pool)
|
||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
|
emailDigestSvc := services.NewEmailDigestService(
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel,
|
||||||
|
)
|
||||||
|
|
||||||
addressHandler := handlers.NewAddressHandler(addressModel)
|
addressHandler := handlers.NewAddressHandler(addressModel)
|
||||||
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
alertRuleHandler := handlers.NewAlertRuleHandler(alertRuleModel, addressModel)
|
||||||
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
alertEventHandler := handlers.NewAlertEventHandler(alertEventModel)
|
||||||
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel)
|
notifConfigHandler := handlers.NewNotificationConfigHandler(notifConfigModel, cfg)
|
||||||
|
emailDigestHandler := handlers.NewEmailDigestHandler(emailDigestSvc, notifConfigModel)
|
||||||
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
b := cfg.APIBasePath // e.g. "/v1"
|
b := cfg.APIBasePath // e.g. "/v1"
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
mux.HandleFunc("GET "+b+"/health", handlers.HealthCheck)
|
||||||
mux.HandleFunc("GET "+b+"/status", handlers.SystemStatus)
|
mux.HandleFunc("GET "+b+"/status", statusHandler.GetStatus)
|
||||||
|
|
||||||
// Authenticated routes — addresses
|
// Authenticated routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
@@ -67,8 +75,10 @@ func main() {
|
|||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
middleware.Authenticate(http.HandlerFunc(addressHandler.List)))
|
||||||
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
mux.Handle("DELETE "+b+"/addresses/{addressId}",
|
||||||
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
middleware.Authenticate(http.HandlerFunc(addressHandler.Remove)))
|
||||||
|
mux.Handle("PATCH "+b+"/addresses/{addressId}",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(addressHandler.UpdateLabel)))
|
||||||
|
|
||||||
// Authenticated routes — alert rules
|
// Authenticated routes for alert rules
|
||||||
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
mux.Handle("POST "+b+"/addresses/{addressId}/alerts",
|
||||||
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
middleware.Authenticate(http.HandlerFunc(alertRuleHandler.Create)))
|
||||||
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
mux.Handle("GET "+b+"/addresses/{addressId}/alerts",
|
||||||
@@ -89,6 +99,14 @@ func main() {
|
|||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.UpdateConfig)))
|
||||||
mux.Handle("DELETE "+b+"/notification-config",
|
mux.Handle("DELETE "+b+"/notification-config",
|
||||||
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.DeleteConfig)))
|
||||||
|
mux.Handle("POST "+b+"/notification-config/test",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(notifConfigHandler.TestChannels)))
|
||||||
|
|
||||||
|
// Authenticated routes — email digest
|
||||||
|
mux.Handle("POST "+b+"/email/setup",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SetupEmail)))
|
||||||
|
mux.Handle("POST "+b+"/email/digest",
|
||||||
|
middleware.Authenticate(http.HandlerFunc(emailDigestHandler.SendDigest)))
|
||||||
|
|
||||||
handler := corsMiddleware(mux)
|
handler := corsMiddleware(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ func main() {
|
|||||||
notifConfigModel := models.NewNotificationConfigModel(pool)
|
notifConfigModel := models.NewNotificationConfigModel(pool)
|
||||||
|
|
||||||
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
observer := services.NewObserverService(eth, addressModel, checkpointModel)
|
||||||
evaluator := services.NewEvaluatorService(eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel)
|
evaluator := services.NewEvaluatorService(
|
||||||
|
eth, alertRuleModel, alertEventModel, addressModel, notifConfigModel,
|
||||||
|
cfg.ResendAPIKey, cfg.EmailFrom,
|
||||||
|
)
|
||||||
|
digestSvc := services.NewEmailDigestService(cfg.ResendAPIKey, cfg.EmailFrom, alertEventModel, notifConfigModel)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -75,12 +79,14 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
interval := time.Duration(cfg.PollIntervalMS) * time.Millisecond
|
||||||
|
digestInterval := time.Duration(cfg.DigestIntervalHours) * time.Hour
|
||||||
|
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
log.Println(strings.Repeat("=", separatorWidth))
|
||||||
log.Println("Koin Ping Observer Poller Starting")
|
log.Println("Koin Ping Observer Poller Starting")
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
log.Println(strings.Repeat("=", separatorWidth))
|
||||||
log.Printf("RPC URL: %s", cfg.EthRPCURL)
|
log.Printf("RPC URL: %s", cfg.EthRPCURL)
|
||||||
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
|
log.Printf("Poll Interval: %dms (%ds)", cfg.PollIntervalMS, cfg.PollIntervalMS/msPerSecond)
|
||||||
|
log.Printf("Digest Interval: %dh", cfg.DigestIntervalHours)
|
||||||
log.Println(strings.Repeat("=", separatorWidth))
|
log.Println(strings.Repeat("=", separatorWidth))
|
||||||
|
|
||||||
runCycle(ctx, observer, evaluator)
|
runCycle(ctx, observer, evaluator)
|
||||||
@@ -88,6 +94,9 @@ func main() {
|
|||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
digestTicker := time.NewTicker(digestInterval)
|
||||||
|
defer digestTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -96,6 +105,13 @@ func main() {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runCycle(ctx, observer, evaluator)
|
runCycle(ctx, observer, evaluator)
|
||||||
|
case <-digestTicker.C:
|
||||||
|
sent, digestErr := digestSvc.SendDigestsForAllUsers(ctx)
|
||||||
|
if digestErr != nil {
|
||||||
|
log.Printf("Email digest failed: %v", digestErr)
|
||||||
|
} else {
|
||||||
|
log.Printf("Sent %d email digests", sent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ require (
|
|||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
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/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.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/detectors/gcp v1.39.0 // indirect
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/resend/resend-go/v3 v3.1.1 h1:Uwpf/tZU+O/r/3nMWE6zUAMIG9dX/vTBS3wlQzYJKSw=
|
||||||
|
github.com/resend/resend-go/v3 v3.1.1/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|||||||
2
backend-go/infra/migrations/003_add_slack_webhook.sql
Normal file
2
backend-go/infra/migrations/003_add_slack_webhook.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE user_notification_configs
|
||||||
|
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;
|
||||||
3
backend-go/infra/migrations/004_alert_event_dedup.sql
Normal file
3
backend-go/infra/migrations/004_alert_event_dedup.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_events_dedup
|
||||||
|
ON alert_events (alert_rule_id, tx_hash)
|
||||||
|
WHERE tx_hash IS NOT NULL;
|
||||||
@@ -54,8 +54,9 @@ CREATE TABLE user_notification_configs (
|
|||||||
user_id VARCHAR(128) PRIMARY KEY,
|
user_id VARCHAR(128) PRIMARY KEY,
|
||||||
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
discord_webhook_url TEXT, -- Discord webhook URL (nullable)
|
||||||
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
telegram_chat_id VARCHAR(128), -- Telegram chat ID (nullable)
|
||||||
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable, future use)
|
telegram_bot_token VARCHAR(255), -- Telegram bot token (nullable)
|
||||||
email VARCHAR(255), -- Email for notifications (nullable)
|
email VARCHAR(255), -- Email for notifications (nullable)
|
||||||
|
slack_webhook_url TEXT, -- Slack incoming webhook URL (nullable)
|
||||||
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
notification_enabled BOOLEAN DEFAULT TRUE, -- Master on/off switch
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
defaultDBPort = 5432
|
defaultDBPort = 5432
|
||||||
defaultPollIntervalMS = 60000
|
defaultPollIntervalMS = 60000
|
||||||
minPollIntervalMS = 1000
|
minPollIntervalMS = 1000
|
||||||
|
defaultDigestIntervalHours = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -28,6 +29,9 @@ type Config struct {
|
|||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
|
ResendAPIKey string
|
||||||
|
EmailFrom string
|
||||||
|
DigestIntervalHours int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
@@ -45,6 +49,9 @@ func Load() (*Config, error) {
|
|||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package database manages PostgreSQL connection pools.
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,21 +10,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxConnIdleSeconds is the maximum idle time for a connection.
|
|
||||||
maxConnIdleSeconds = 30
|
maxConnIdleSeconds = 30
|
||||||
// maxConnLifetimeMinutes is the maximum lifetime for a connection.
|
|
||||||
maxConnLifetimeMinutes = 5
|
maxConnLifetimeMinutes = 5
|
||||||
// connectTimeoutSeconds is the timeout for initial connection.
|
|
||||||
connectTimeoutSeconds = 10
|
connectTimeoutSeconds = 10
|
||||||
// maxConns is the maximum number of connections in the pool.
|
|
||||||
maxConns = 20
|
maxConns = 20
|
||||||
// minConns is the minimum number of connections in the pool.
|
|
||||||
minConns = 2
|
minConns = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
var pool *pgxpool.Pool //nolint:gochecknoglobals
|
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) {
|
func Connect(dsn string) (*pgxpool.Pool, error) {
|
||||||
cfg, err := pgxpool.ParseConfig(dsn)
|
cfg, err := pgxpool.ParseConfig(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,12 +56,10 @@ func Connect(dsn string) (*pgxpool.Pool, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool returns the global connection pool.
|
|
||||||
func Pool() *pgxpool.Pool {
|
func Pool() *pgxpool.Pool {
|
||||||
return pool
|
return pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the global connection pool.
|
|
||||||
func Close() {
|
func Close() {
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
pool.Close()
|
pool.Close()
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// Package domain defines core domain types shared across the application.
|
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Address represents a tracked Ethereum address.
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
@@ -12,13 +10,10 @@ type Address struct {
|
|||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertType identifies the kind of alert rule.
|
|
||||||
type AlertType string
|
type AlertType string
|
||||||
|
|
||||||
// String implements fmt.Stringer.
|
|
||||||
func (a AlertType) String() string { return string(a) }
|
func (a AlertType) String() string { return string(a) }
|
||||||
|
|
||||||
// Alert type constants define the supported alert triggers.
|
|
||||||
const (
|
const (
|
||||||
AlertIncomingTx AlertType = "incoming_tx"
|
AlertIncomingTx AlertType = "incoming_tx"
|
||||||
AlertOutgoingTx AlertType = "outgoing_tx"
|
AlertOutgoingTx AlertType = "outgoing_tx"
|
||||||
@@ -26,7 +21,6 @@ const (
|
|||||||
AlertBalanceBelow AlertType = "balance_below"
|
AlertBalanceBelow AlertType = "balance_below"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidAlertTypes lists all alert types accepted by the API.
|
|
||||||
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
var ValidAlertTypes = []AlertType{ //nolint:gochecknoglobals
|
||||||
AlertIncomingTx,
|
AlertIncomingTx,
|
||||||
AlertOutgoingTx,
|
AlertOutgoingTx,
|
||||||
@@ -62,7 +56,6 @@ func IsThresholdRequired(t AlertType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertRule represents a user-defined alert rule for an address.
|
|
||||||
type AlertRule struct {
|
type AlertRule struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
@@ -72,7 +65,6 @@ type AlertRule struct {
|
|||||||
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlertEvent represents a fired alert event stored for history.
|
|
||||||
type AlertEvent struct {
|
type AlertEvent struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
AlertRuleID int `json:"alert_rule_id"` //nolint:tagliatelle
|
||||||
@@ -82,7 +74,6 @@ type AlertEvent struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddressCheckpoint tracks the last block checked for an address.
|
|
||||||
type AddressCheckpoint struct {
|
type AddressCheckpoint struct {
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
LastCheckedBlock int `json:"last_checked_block"` //nolint:tagliatelle
|
||||||
@@ -103,8 +94,9 @@ type NotificationConfig struct {
|
|||||||
UserID string `json:"user_id"` //nolint:tagliatelle
|
UserID string `json:"user_id"` //nolint:tagliatelle
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
DiscordWebhookURL *string `json:"discord_webhook_url"` //nolint:tagliatelle
|
||||||
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
TelegramChatID *string `json:"telegram_chat_id"` //nolint:tagliatelle
|
||||||
TelegramBotToken *string `json:"telegram_bot_token,omitempty"` //nolint:tagliatelle
|
TelegramBotToken *string `json:"telegram_bot_token"` //nolint:tagliatelle
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
|
SlackWebhookURL *string `json:"slack_webhook_url"` //nolint:tagliatelle
|
||||||
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
NotificationEnabled bool `json:"notification_enabled"` //nolint:tagliatelle
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
CreatedAt *time.Time `json:"created_at,omitempty"` //nolint:tagliatelle
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` //nolint:tagliatelle
|
||||||
@@ -118,21 +110,29 @@ type NormalizedTx struct {
|
|||||||
Value string `json:"value"` // Wei as string for precision
|
Value string `json:"value"` // Wei as string for precision
|
||||||
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
BlockNumber int `json:"block_number"` //nolint:tagliatelle
|
||||||
BlockTimestamp int64 `json:"block_timestamp"` //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
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer.
|
||||||
|
func (tx NormalizedTx) IsTokenTransfer() bool {
|
||||||
|
return tx.TokenContract != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction indicates whether a transaction is incoming or outgoing.
|
|
||||||
type Direction string
|
type Direction string
|
||||||
|
|
||||||
// String implements fmt.Stringer.
|
// String implements fmt.Stringer.
|
||||||
func (d Direction) String() string { return string(d) }
|
func (d Direction) String() string { return string(d) }
|
||||||
|
|
||||||
// Direction constants indicate the flow of a transaction relative to a watched address.
|
|
||||||
const (
|
const (
|
||||||
DirectionIncoming Direction = "incoming"
|
DirectionIncoming Direction = "incoming"
|
||||||
DirectionOutgoing Direction = "outgoing"
|
DirectionOutgoing Direction = "outgoing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ObservedTx is a NormalizedTx enriched with address and direction context.
|
|
||||||
type ObservedTx struct {
|
type ObservedTx struct {
|
||||||
NormalizedTx
|
NormalizedTx
|
||||||
AddressID int `json:"address_id"` //nolint:tagliatelle
|
AddressID int `json:"address_id"` //nolint:tagliatelle
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package firebase provides Firebase authentication integration.
|
|
||||||
package firebase
|
package firebase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,7 +16,6 @@ var ( //nolint:gochecknoglobals
|
|||||||
errInit error //nolint:gochecknoglobals
|
errInit error //nolint:gochecknoglobals
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init initializes the Firebase app and auth client using the given project ID.
|
|
||||||
func Init(projectID string) error {
|
func Init(projectID string) error {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -48,7 +46,6 @@ func Init(projectID string) error {
|
|||||||
return errInit
|
return errInit
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth returns the initialized Firebase auth client.
|
|
||||||
func Auth() *auth.Client {
|
func Auth() *auth.Client {
|
||||||
return authClient
|
return authClient
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,14 @@ import (
|
|||||||
|
|
||||||
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
var ethAddressRe = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`)
|
||||||
|
|
||||||
// AddressHandler handles HTTP requests for address management.
|
|
||||||
type AddressHandler struct {
|
type AddressHandler struct {
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAddressHandler creates a new AddressHandler.
|
|
||||||
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
func NewAddressHandler(addresses *models.AddressModel) *AddressHandler {
|
||||||
return &AddressHandler{addresses: addresses}
|
return &AddressHandler{addresses: addresses}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create handles POST requests to add a new tracked address.
|
|
||||||
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *AddressHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
@@ -92,6 +89,44 @@ func (h *AddressHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, addresses)
|
writeJSON(w, http.StatusOK, addresses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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"))
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid address ID")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Label *string `json:"label"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid request body")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("User %s updating label for address ID: %d", userID, addressID)
|
||||||
|
|
||||||
|
addr, err := h.addresses.UpdateLabel(r.Context(), addressID, userID, body.Label)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating address label: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update address")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "Address not found")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, addr)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove handles DELETE requests to remove a tracked address.
|
// Remove handles DELETE requests to remove a tracked address.
|
||||||
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
func (h *AddressHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
@@ -51,45 +50,9 @@ func (h *AlertEventHandler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
log.Printf("Found %d alert events for user", len(events))
|
log.Printf("Found %d alert events for user", len(events))
|
||||||
|
|
||||||
// MVP scaffolding: return mock data if DB is empty
|
if events == nil {
|
||||||
if len(events) == 0 {
|
events = []domain.AlertEvent{}
|
||||||
events = mockEvents(limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, events)
|
writeJSON(w, http.StatusOK, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mockEvents(limit int) []domain.AlertEvent {
|
|
||||||
label1 := "Treasury Wallet"
|
|
||||||
label2 := "Cold Storage"
|
|
||||||
|
|
||||||
mocks := []domain.AlertEvent{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
AlertRuleID: 1,
|
|
||||||
Message: "Incoming transaction detected: 5.5 ETH received",
|
|
||||||
AddressLabel: &label1,
|
|
||||||
Timestamp: time.Now().Add(-2 * time.Hour),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2, //nolint:mnd
|
|
||||||
AlertRuleID: 2, //nolint:mnd
|
|
||||||
Message: "Balance dropped below threshold: Current balance 8.2 ETH",
|
|
||||||
AddressLabel: &label1,
|
|
||||||
Timestamp: time.Now().Add(-5 * time.Hour),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3, //nolint:mnd
|
|
||||||
AlertRuleID: 3, //nolint:mnd
|
|
||||||
Message: "Outgoing transaction detected: 2.0 ETH sent",
|
|
||||||
AddressLabel: &label2,
|
|
||||||
Timestamp: time.Now().Add(-24 * time.Hour),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit < len(mocks) {
|
|
||||||
return mocks[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
return mocks
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,21 +14,17 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errThresholdFormat is returned when the threshold JSON cannot be decoded.
|
|
||||||
var errThresholdFormat = errors.New("unsupported threshold format")
|
var errThresholdFormat = errors.New("unsupported threshold format")
|
||||||
|
|
||||||
// AlertRuleHandler handles HTTP requests for alert rule management.
|
|
||||||
type AlertRuleHandler struct {
|
type AlertRuleHandler struct {
|
||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAlertRuleHandler creates a new AlertRuleHandler.
|
|
||||||
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
func NewAlertRuleHandler(alertRules *models.AlertRuleModel, addresses *models.AddressModel) *AlertRuleHandler {
|
||||||
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
return &AlertRuleHandler{alertRules: alertRules, addresses: addresses}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create handles POST requests to create a new alert rule for an address.
|
|
||||||
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *AlertRuleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := middleware.GetUserID(r.Context())
|
userID := middleware.GetUserID(r.Context())
|
||||||
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
addressID, ok := parseIntParam(r.PathValue("addressId"))
|
||||||
|
|||||||
101
backend-go/internal/handlers/email_digest.go
Normal file
101
backend-go/internal/handlers/email_digest.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailDigestHandler struct {
|
||||||
|
digestSvc *services.EmailDigestService
|
||||||
|
configs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestHandler(
|
||||||
|
digestSvc *services.EmailDigestService,
|
||||||
|
configs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestHandler {
|
||||||
|
return &EmailDigestHandler{digestSvc: digestSvc, configs: configs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail reads the user's email from their notification config and sends
|
||||||
|
// a confirmation message via Resend to verify the integration works.
|
||||||
|
func (h *EmailDigestHandler) SetupEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for email setup: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"Save an email address in notification settings first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SetupEmail(*cfg.Email); err != nil {
|
||||||
|
log.Printf("Email setup failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "EMAIL_SEND_FAILED",
|
||||||
|
"Failed to send confirmation email — check server email config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Email setup confirmation sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Confirmation email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDigest compiles and sends a digest of recent alerts to the user's email.
|
||||||
|
func (h *EmailDigestHandler) SendDigest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
|
||||||
|
if !h.digestSvc.Configured() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "EMAIL_NOT_CONFIGURED",
|
||||||
|
"Email service is not configured on the server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for digest: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR",
|
||||||
|
"Failed to load notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil || cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"No email address configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.digestSvc.SendDigest(r.Context(), userID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Digest send failed for user %s: %v", userID, err)
|
||||||
|
writeError(w, http.StatusBadGateway, "DIGEST_SEND_FAILED",
|
||||||
|
"Failed to send digest email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Digest sent to user %s (%s)", userID, *cfg.Email)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"success": true,
|
||||||
|
"email": *cfg.Email,
|
||||||
|
"message": "Digest email sent",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,19 +7,22 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"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/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
"github.com/kjannette/koin-ping/backend-go/internal/middleware"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/notifications"
|
||||||
)
|
)
|
||||||
|
|
||||||
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
type NotificationConfigHandler struct {
|
type NotificationConfigHandler struct {
|
||||||
configs *models.NotificationConfigModel
|
configs *models.NotificationConfigModel
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationConfigHandler(configs *models.NotificationConfigModel) *NotificationConfigHandler {
|
func NewNotificationConfigHandler(configs *models.NotificationConfigModel, cfg *config.Config) *NotificationConfigHandler {
|
||||||
return &NotificationConfigHandler{configs: configs}
|
return &NotificationConfigHandler{configs: configs, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *NotificationConfigHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -51,7 +54,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
var body struct {
|
var body struct {
|
||||||
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
DiscordWebhookURL *string `json:"discord_webhook_url"`
|
||||||
TelegramChatID *string `json:"telegram_chat_id"`
|
TelegramChatID *string `json:"telegram_chat_id"`
|
||||||
|
TelegramBotToken *string `json:"telegram_bot_token"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
|
SlackWebhookURL *string `json:"slack_webhook_url"`
|
||||||
NotificationEnabled *bool `json:"notification_enabled"`
|
NotificationEnabled *bool `json:"notification_enabled"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
@@ -63,7 +68,8 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
log.Printf("User %s updating notification config", userID)
|
log.Printf("User %s updating notification config", userID)
|
||||||
|
|
||||||
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
if body.DiscordWebhookURL == nil && body.TelegramChatID == nil &&
|
||||||
body.Email == nil && body.NotificationEnabled == nil {
|
body.TelegramBotToken == nil && body.Email == nil &&
|
||||||
|
body.SlackWebhookURL == nil && body.NotificationEnabled == nil {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"At least one configuration field must be provided")
|
"At least one configuration field must be provided")
|
||||||
return
|
return
|
||||||
@@ -76,6 +82,13 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body.SlackWebhookURL != nil && *body.SlackWebhookURL != "" &&
|
||||||
|
!strings.HasPrefix(*body.SlackWebhookURL, "https://hooks.slack.com/") {
|
||||||
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
|
"Invalid Slack webhook URL format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
|
if body.Email != nil && *body.Email != "" && !emailRe.MatchString(*body.Email) {
|
||||||
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
"Invalid email address format")
|
"Invalid email address format")
|
||||||
@@ -90,7 +103,9 @@ func (h *NotificationConfigHandler) UpdateConfig(w http.ResponseWriter, r *http.
|
|||||||
cfg := domain.NotificationConfig{
|
cfg := domain.NotificationConfig{
|
||||||
DiscordWebhookURL: body.DiscordWebhookURL,
|
DiscordWebhookURL: body.DiscordWebhookURL,
|
||||||
TelegramChatID: body.TelegramChatID,
|
TelegramChatID: body.TelegramChatID,
|
||||||
|
TelegramBotToken: body.TelegramBotToken,
|
||||||
Email: body.Email,
|
Email: body.Email,
|
||||||
|
SlackWebhookURL: body.SlackWebhookURL,
|
||||||
NotificationEnabled: enabled,
|
NotificationEnabled: enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +141,76 @@ func (h *NotificationConfigHandler) DeleteConfig(w http.ResponseWriter, r *http.
|
|||||||
log.Println("Notification config deleted")
|
log.Println("Notification config deleted")
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChannels sends a test message to all configured notification channels.
|
||||||
|
func (h *NotificationConfigHandler) TestChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := middleware.GetUserID(r.Context())
|
||||||
|
log.Printf("User %s testing notification channels", userID)
|
||||||
|
|
||||||
|
cfg, err := h.configs.GetConfig(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting notification config for test: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get notification config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "NOT_FOUND", "No notification configuration found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelResult struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []channelResult
|
||||||
|
|
||||||
|
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestDiscordWebhook(*cfg.DiscordWebhookURL)
|
||||||
|
res := channelResult{Channel: "discord", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
|
ok, testErr := notifications.TestTelegramWebhook(*cfg.TelegramBotToken, *cfg.TelegramChatID)
|
||||||
|
res := channelResult{Channel: "telegram", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
|
ok, testErr := notifications.TestSlackWebhook(*cfg.SlackWebhookURL)
|
||||||
|
res := channelResult{Channel: "slack", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil && *cfg.Email != "" {
|
||||||
|
ok, testErr := notifications.TestEmailNotification(
|
||||||
|
h.cfg.ResendAPIKey, h.cfg.EmailFrom, *cfg.Email,
|
||||||
|
)
|
||||||
|
res := channelResult{Channel: "email", Success: ok}
|
||||||
|
if testErr != nil {
|
||||||
|
res.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "NO_CHANNELS",
|
||||||
|
"No notification channels are configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,67 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StatusHandler handles the system status endpoint.
|
||||||
|
type StatusHandler struct {
|
||||||
|
checkpoints *models.CheckpointModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatusHandler creates a new StatusHandler.
|
||||||
|
func NewStatusHandler(checkpoints *models.CheckpointModel) *StatusHandler {
|
||||||
|
return &StatusHandler{checkpoints: checkpoints}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns real-time system status derived from checkpoint data.
|
||||||
|
func (h *StatusHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
block, checkedAt, err := h.checkpoints.GetLatestBlock(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error querying latest block: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get system status")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestBlock := 0
|
||||||
|
lag := 0
|
||||||
|
status := "starting"
|
||||||
|
|
||||||
|
if checkedAt != nil {
|
||||||
|
lag = int(time.Since(*checkedAt).Seconds())
|
||||||
|
if lag > 600 { //nolint:mnd
|
||||||
|
status = "idle"
|
||||||
|
} else {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if block != nil {
|
||||||
|
latestBlock = *block
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"latestBlock": latestBlock,
|
||||||
|
"lag": lag,
|
||||||
|
"lastCheckedAt": checkedAtStr(checkedAt),
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkedAtStr(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -12,12 +69,3 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
"service": "koin-ping-backend",
|
"service": "koin-ping-backend",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func SystemStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"latestBlock": 0,
|
|
||||||
"lag": 0,
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ func (m *AddressModel) FindByID(ctx context.Context, id int, userID *string) (*d
|
|||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLabel updates the label for an address owned by userID.
|
||||||
|
// Returns nil, nil if no row matched (address not found or not owned by user).
|
||||||
|
func (m *AddressModel) UpdateLabel(ctx context.Context, id int, userID string, label *string) (*domain.Address, error) {
|
||||||
|
var a domain.Address
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`UPDATE addresses SET label = $3 WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING id, user_id, address, label, created_at`,
|
||||||
|
id, userID, label,
|
||||||
|
).Scan(&a.ID, &a.UserID, &a.Address, &a.Label, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
func (m *AddressModel) Remove(ctx context.Context, id int, userID string) (bool, error) {
|
||||||
tag, err := m.pool.Exec(ctx,
|
tag, err := m.pool.Exec(ctx,
|
||||||
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
`DELETE FROM addresses WHERE id = $1 AND user_id = $2`,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -71,10 +73,15 @@ func (m *AlertEventModel) Create(ctx context.Context, alertRuleID int, message s
|
|||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
`INSERT INTO alert_events (alert_rule_id, message, address_label, tx_hash)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
RETURNING id, alert_rule_id, message, address_label, tx_hash, timestamp`,
|
||||||
alertRuleID, message, addressLabel, txHash,
|
alertRuleID, message, addressLabel, txHash,
|
||||||
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
).Scan(&e.ID, &e.AlertRuleID, &e.Message, &e.AddressLabel, &e.TxHash, &e.Timestamp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Duplicate silently skipped by ON CONFLICT DO NOTHING
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &e, nil
|
return &e, nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package models
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -17,6 +18,20 @@ func NewCheckpointModel(pool *pgxpool.Pool) *CheckpointModel {
|
|||||||
return &CheckpointModel{pool: pool}
|
return &CheckpointModel{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLatestBlock returns the highest last_checked_block and its timestamp across all addresses.
|
||||||
|
// Returns nil, nil, nil when no checkpoints exist yet.
|
||||||
|
func (m *CheckpointModel) GetLatestBlock(ctx context.Context) (*int, *time.Time, error) {
|
||||||
|
var block *int
|
||||||
|
var checkedAt *time.Time
|
||||||
|
err := m.pool.QueryRow(ctx,
|
||||||
|
`SELECT MAX(last_checked_block), MAX(last_checked_at) FROM address_checkpoints`,
|
||||||
|
).Scan(&block, &checkedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return block, checkedAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
// GetLastCheckedBlock returns the last checked block for an address, or -1 if never checked.
|
||||||
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
func (m *CheckpointModel) GetLastCheckedBlock(ctx context.Context, addressID int) (int, bool, error) {
|
||||||
var block int
|
var block int
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ func (m *NotificationConfigModel) GetConfig(ctx context.Context, userID string)
|
|||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at
|
email, slack_webhook_url, notification_enabled, created_at, updated_at
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE user_id = $1`,
|
WHERE user_id = $1`,
|
||||||
userID,
|
userID,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -40,22 +40,24 @@ func (m *NotificationConfigModel) UpsertConfig(ctx context.Context, userID strin
|
|||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
err := m.pool.QueryRow(ctx,
|
err := m.pool.QueryRow(ctx,
|
||||||
`INSERT INTO user_notification_configs
|
`INSERT INTO user_notification_configs
|
||||||
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token, email, notification_enabled, updated_at)
|
(user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
email, slack_webhook_url, notification_enabled, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
ON CONFLICT (user_id)
|
ON CONFLICT (user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
discord_webhook_url = COALESCE($2, user_notification_configs.discord_webhook_url),
|
discord_webhook_url = $2,
|
||||||
telegram_chat_id = COALESCE($3, user_notification_configs.telegram_chat_id),
|
telegram_chat_id = $3,
|
||||||
telegram_bot_token = COALESCE($4, user_notification_configs.telegram_bot_token),
|
telegram_bot_token = $4,
|
||||||
email = COALESCE($5, user_notification_configs.email),
|
email = $5,
|
||||||
notification_enabled = $6,
|
slack_webhook_url = $6,
|
||||||
|
notification_enabled = $7,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
RETURNING user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
email, notification_enabled, created_at, updated_at`,
|
email, slack_webhook_url, notification_enabled, created_at, updated_at`,
|
||||||
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
userID, cfg.DiscordWebhookURL, cfg.TelegramChatID, cfg.TelegramBotToken,
|
||||||
cfg.Email, cfg.NotificationEnabled,
|
cfg.Email, cfg.SlackWebhookURL, cfg.NotificationEnabled,
|
||||||
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
).Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.TelegramBotToken,
|
||||||
&c.Email, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
&c.Email, &c.SlackWebhookURL, &c.NotificationEnabled, &c.CreatedAt, &c.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,8 @@ func (m *NotificationConfigModel) Remove(ctx context.Context, userID string) (bo
|
|||||||
|
|
||||||
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.NotificationConfig, error) {
|
||||||
rows, err := m.pool.Query(ctx,
|
rows, err := m.pool.Query(ctx,
|
||||||
`SELECT user_id, discord_webhook_url, telegram_chat_id, email
|
`SELECT user_id, discord_webhook_url, telegram_chat_id, telegram_bot_token,
|
||||||
|
email, slack_webhook_url
|
||||||
FROM user_notification_configs
|
FROM user_notification_configs
|
||||||
WHERE notification_enabled = TRUE`,
|
WHERE notification_enabled = TRUE`,
|
||||||
)
|
)
|
||||||
@@ -87,7 +90,8 @@ func (m *NotificationConfigModel) ListEnabled(ctx context.Context) ([]domain.Not
|
|||||||
var configs []domain.NotificationConfig
|
var configs []domain.NotificationConfig
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c domain.NotificationConfig
|
var c domain.NotificationConfig
|
||||||
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID, &c.Email); err != nil {
|
if err := rows.Scan(&c.UserID, &c.DiscordWebhookURL, &c.TelegramChatID,
|
||||||
|
&c.TelegramBotToken, &c.Email, &c.SlackWebhookURL); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.NotificationEnabled = true
|
c.NotificationEnabled = true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package notifications
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -25,11 +26,15 @@ var discordHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
|||||||
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
Timeout: discordHTTPTimeoutSeconds * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMetadata struct {
|
// DiscordNotifier sends alert notifications via a Discord webhook.
|
||||||
TxHash string
|
type DiscordNotifier struct {
|
||||||
AddressLabel string
|
WebhookURL string
|
||||||
AlertType string
|
}
|
||||||
Address 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordEmbed struct {
|
type discordEmbed struct {
|
||||||
|
|||||||
156
backend-go/internal/notifications/email.go
Normal file
156
backend-go/internal/notifications/email.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var emailHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: emailHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendEmailNotification(apiKey, fromAddress, toAddress, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Printf("Skipping email notification: RESEND_API_KEY not configured")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Koin Ping Alert: %s", alertTypeLabel(meta.AlertType))
|
||||||
|
|
||||||
|
txLink := ""
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
txLink = fmt.Sprintf(
|
||||||
|
`<p><a href="https://etherscan.io/tx/%s">View on Etherscan</a></p>`,
|
||||||
|
meta.TxHash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping Alert</h2>
|
||||||
|
<p style="font-size: 16px;">%s</p>
|
||||||
|
<table style="margin: 16px 0; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Address</td>
|
||||||
|
<td style="padding: 4px 0;">%s</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 4px 12px 4px 0; color: #666;">Blockchain</td>
|
||||||
|
<td style="padding: 4px 0; font-family: monospace; font-size: 13px;">%s</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
%s
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`,
|
||||||
|
message, meta.AddressLabel, meta.Address, txLink)
|
||||||
|
|
||||||
|
payload := resendPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("create email request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send email notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("resend API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailNotification(apiKey, fromAddress, toAddress string) (bool, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return false, fmt.Errorf("email not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := resendPayload{
|
||||||
|
From: fromAddress,
|
||||||
|
To: toAddress,
|
||||||
|
Subject: "Koin Ping — Test Notification",
|
||||||
|
HTML: `<p>Your email alerts are configured correctly!</p><p style="font-size:12px;color:#999;">Sent by Koin Ping</p>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
resp, err := emailHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Email test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertTypeLabel(alertType string) string {
|
||||||
|
switch alertType {
|
||||||
|
case "incoming_tx":
|
||||||
|
return "Incoming Transaction"
|
||||||
|
case "outgoing_tx":
|
||||||
|
return "Outgoing Transaction"
|
||||||
|
case "large_transfer":
|
||||||
|
return "Large Transfer"
|
||||||
|
case "balance_below":
|
||||||
|
return "Balance Below Threshold"
|
||||||
|
default:
|
||||||
|
return "Alert"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend-go/internal/notifications/notifier.go
Normal file
16
backend-go/internal/notifications/notifier.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
128
backend-go/internal/notifications/slack.go
Normal file
128
backend-go/internal/notifications/slack.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlackNotifier sends alert notifications via a Slack webhook.
|
||||||
|
type SlackNotifier struct {
|
||||||
|
WebhookURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Notifier for Slack.
|
||||||
|
func (s *SlackNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendSlackNotification(s.WebhookURL, message, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const slackHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var slackHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: slackHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackAttachment struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Fields []slackField `json:"fields"`
|
||||||
|
Footer string `json:"footer"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackField struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Short bool `json:"short"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackPayload struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Attachments []slackAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
fields := []slackField{
|
||||||
|
{Title: "Address", Value: meta.AddressLabel, Short: true},
|
||||||
|
{Title: "Blockchain Address", Value: fmt.Sprintf("`%s`", meta.Address), Short: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
fields = append(fields, slackField{
|
||||||
|
Title: "Transaction",
|
||||||
|
Value: fmt.Sprintf("<https://etherscan.io/tx/%s|View on Etherscan>", meta.TxHash),
|
||||||
|
Short: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := slackPayload{
|
||||||
|
Attachments: []slackAttachment{
|
||||||
|
{
|
||||||
|
Color: slackColorForAlertType(meta.AlertType),
|
||||||
|
Title: "Koin Ping Alert",
|
||||||
|
Text: message,
|
||||||
|
Fields: fields,
|
||||||
|
Footer: "Koin Ping",
|
||||||
|
Ts: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send Slack notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||||
|
payload := slackPayload{
|
||||||
|
Text: "Koin Ping test notification — Your Slack alerts are configured correctly!",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := slackHTTPClient.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Slack webhook test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func slackColorForAlertType(alertType string) string {
|
||||||
|
switch alertType {
|
||||||
|
case "incoming_tx":
|
||||||
|
return "#00ff00"
|
||||||
|
case "outgoing_tx":
|
||||||
|
return "#ff9900"
|
||||||
|
case "large_transfer", "balance_below":
|
||||||
|
return "#ff0000"
|
||||||
|
default:
|
||||||
|
return "#0099ff"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
backend-go/internal/notifications/telegram.go
Normal file
122
backend-go/internal/notifications/telegram.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramNotifier sends alert notifications via Telegram.
|
||||||
|
type TelegramNotifier struct {
|
||||||
|
BotToken string
|
||||||
|
ChatID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send implements Notifier for Telegram.
|
||||||
|
func (t *TelegramNotifier) Send(_ context.Context, message string, meta AlertMetadata) error {
|
||||||
|
_, err := SendTelegramNotification(t.BotToken, t.ChatID, message, meta)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramHTTPTimeoutSeconds = 10
|
||||||
|
|
||||||
|
var telegramHTTPClient = &http.Client{ //nolint:gochecknoglobals
|
||||||
|
Timeout: telegramHTTPTimeoutSeconds * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramPayload struct {
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ParseMode string `json:"parse_mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendTelegramNotification(botToken, chatID, message string, meta AlertMetadata) (bool, error) {
|
||||||
|
text := fmt.Sprintf("*Koin Ping Alert*\n\n%s\n\n*Address:* %s\n`%s`",
|
||||||
|
escapeMarkdown(message), escapeMarkdown(meta.AddressLabel), meta.Address)
|
||||||
|
|
||||||
|
if meta.TxHash != "" {
|
||||||
|
text += fmt.Sprintf("\n\n[View on Etherscan](https://etherscan.io/tx/%s)", meta.TxHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: text,
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("marshal telegram payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send Telegram notification: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Printf("Telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
return false, fmt.Errorf("telegram API failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramWebhook(botToken, chatID string) (bool, error) {
|
||||||
|
payload := telegramPayload{
|
||||||
|
ChatID: chatID,
|
||||||
|
Text: "Koin Ping test notification — Your Telegram alerts are configured correctly!",
|
||||||
|
ParseMode: "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||||
|
|
||||||
|
resp, err := telegramHTTPClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Telegram test failed: %v", err)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMarkdown(s string) string {
|
||||||
|
replacer := []struct{ old, new string }{
|
||||||
|
{"_", "\\_"}, {"*", "\\*"}, {"[", "\\["}, {"]", "\\]"},
|
||||||
|
{"(", "\\("}, {")", "\\)"}, {"~", "\\~"}, {"`", "\\`"},
|
||||||
|
{">", "\\>"}, {"#", "\\#"}, {"+", "\\+"}, {"-", "\\-"},
|
||||||
|
{"=", "\\="}, {"|", "\\|"}, {"{", "\\{"}, {"}", "\\}"},
|
||||||
|
{".", "\\."}, {"!", "\\!"},
|
||||||
|
}
|
||||||
|
result := s
|
||||||
|
for _, r := range replacer {
|
||||||
|
result = replaceAll(result, r.old, r.new)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
out := ""
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if string(s[i]) == old {
|
||||||
|
out += new
|
||||||
|
} else {
|
||||||
|
out += string(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,7 +15,11 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rpcTimeoutMS = 30000
|
const (
|
||||||
|
rpcTimeoutMS = 30000
|
||||||
|
rpcMaxRetries = 3
|
||||||
|
rpcRetryBaseMS = 1000
|
||||||
|
)
|
||||||
|
|
||||||
type JsonRpcEthereum struct {
|
type JsonRpcEthereum struct {
|
||||||
rpcURL string
|
rpcURL string
|
||||||
@@ -66,6 +72,44 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
|||||||
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
return nil, fmt.Errorf("marshal RPC request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return j.callWithRetry(ctx, method, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithRetry executes a JSON-RPC POST with exponential backoff on transient errors.
|
||||||
|
// It retries on network errors, HTTP 429, and HTTP 5xx. It does NOT retry on RPC-level
|
||||||
|
// errors or other 4xx responses (those are permanent failures).
|
||||||
|
func (j *JsonRpcEthereum) callWithRetry(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := range rpcMaxRetries {
|
||||||
|
if attempt > 0 {
|
||||||
|
wait := time.Duration(rpcRetryBaseMS*(1<<(attempt-1))) * time.Millisecond
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
log.Printf("Retrying RPC call [%s] (attempt %d/%d)", method, attempt+1, rpcMaxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := j.doRPCCall(ctx, method, body)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
// Permanent errors: do not retry
|
||||||
|
if isPermanentRPCError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Transient RPC error [%s] (attempt %d/%d): %v", method, attempt+1, rpcMaxRetries, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JsonRpcEthereum) doRPCCall(ctx context.Context, method string, body []byte) (json.RawMessage, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.rpcURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create RPC request: %w", err)
|
return nil, fmt.Errorf("create RPC request: %w", err)
|
||||||
@@ -78,8 +122,13 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 429 and 5xx are transient; other non-200 are permanent.
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
err := fmt.Errorf("HTTP %d: %s for %s", resp.StatusCode, resp.Status, method)
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { //nolint:mnd
|
||||||
|
return nil, err // transient — will be retried
|
||||||
|
}
|
||||||
|
return nil, &permanentRPCError{err}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rpcResp rpcResponse
|
var rpcResp rpcResponse
|
||||||
@@ -88,12 +137,25 @@ func (j *JsonRpcEthereum) callRPC(ctx context.Context, method string, params ...
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rpcResp.Error != nil {
|
if rpcResp.Error != nil {
|
||||||
return nil, fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code)
|
// RPC-level errors are permanent (bad params, unsupported method, etc.)
|
||||||
|
return nil, &permanentRPCError{
|
||||||
|
fmt.Errorf("RPC Error [%s]: %s (code: %d)", method, rpcResp.Error.Message, rpcResp.Error.Code),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rpcResp.Result, nil
|
return rpcResp.Result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type permanentRPCError struct{ cause error }
|
||||||
|
|
||||||
|
func (e *permanentRPCError) Error() string { return e.cause.Error() }
|
||||||
|
func (e *permanentRPCError) Unwrap() error { return e.cause }
|
||||||
|
|
||||||
|
func isPermanentRPCError(err error) bool {
|
||||||
|
var p *permanentRPCError
|
||||||
|
return errors.As(err, &p)
|
||||||
|
}
|
||||||
|
|
||||||
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
func (j *JsonRpcEthereum) GetLatestBlockNumber(ctx context.Context) (int, error) {
|
||||||
result, err := j.callRPC(ctx, "eth_blockNumber")
|
result, err := j.callRPC(ctx, "eth_blockNumber")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin
|
|||||||
return hexToDecimalString(hexBalance), nil
|
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) {
|
func hexToInt(hex string) (int, error) {
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
hex = strings.TrimPrefix(hex, "0x")
|
||||||
n, ok := new(big.Int).SetString(hex, 16)
|
n, ok := new(big.Int).SetString(hex, 16)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ type EthereumObserver interface {
|
|||||||
GetLatestBlockNumber(ctx context.Context) (int, error)
|
GetLatestBlockNumber(ctx context.Context) (int, error)
|
||||||
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error)
|
||||||
GetBalance(ctx context.Context, address string) (string, error)
|
GetBalance(ctx context.Context, address string) (string, error)
|
||||||
|
GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error)
|
||||||
}
|
}
|
||||||
|
|||||||
34
backend-go/internal/protocols/ethereum/tokens.go
Normal file
34
backend-go/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
|
||||||
|
}
|
||||||
199
backend-go/internal/services/email_digest.go
Normal file
199
backend-go/internal/services/email_digest.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
resendAPIURL = "https://api.resend.com/emails"
|
||||||
|
emailHTTPTimeout = 10 * time.Second
|
||||||
|
defaultDigestMaxItems = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
var digestHTTPClient = &http.Client{Timeout: emailHTTPTimeout} //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// EmailDigestService handles email setup and digest sending via Resend.
|
||||||
|
type EmailDigestService struct {
|
||||||
|
apiKey string
|
||||||
|
fromAddress string
|
||||||
|
alertEvents *models.AlertEventModel
|
||||||
|
notifCfgs *models.NotificationConfigModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailDigestService(
|
||||||
|
apiKey, fromAddress string,
|
||||||
|
alertEvents *models.AlertEventModel,
|
||||||
|
notifCfgs *models.NotificationConfigModel,
|
||||||
|
) *EmailDigestService {
|
||||||
|
return &EmailDigestService{
|
||||||
|
apiKey: apiKey,
|
||||||
|
fromAddress: fromAddress,
|
||||||
|
alertEvents: alertEvents,
|
||||||
|
notifCfgs: notifCfgs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true when the Resend API key is present.
|
||||||
|
func (s *EmailDigestService) Configured() bool {
|
||||||
|
return s.apiKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupEmail validates the email works by sending a welcome/confirmation
|
||||||
|
// message via Resend. Called when a user saves their email in notification settings.
|
||||||
|
func (s *EmailDigestService) SetupEmail(toAddress string) error {
|
||||||
|
if !s.Configured() {
|
||||||
|
return fmt.Errorf("email service not configured: RESEND_API_KEY not set") //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
html := `
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Welcome to Koin Ping Email Alerts</h2>
|
||||||
|
<p>Your email has been successfully configured for alert notifications.</p>
|
||||||
|
<p>You will receive alert digests at this address when events are triggered
|
||||||
|
on your watched addresses.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 24px 0;" />
|
||||||
|
<p style="font-size: 12px; color: #999;">Sent by Koin Ping</p>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := s.alertEvents.ListRecentByUser(ctx, userID, defaultDigestMaxItems)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch alert events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) == 0 {
|
||||||
|
log.Printf("No recent alerts for user %s — skipping digest", userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows string
|
||||||
|
for _, e := range events {
|
||||||
|
label := "—"
|
||||||
|
if e.AddressLabel != nil {
|
||||||
|
label = *e.AddressLabel
|
||||||
|
}
|
||||||
|
txLink := "—"
|
||||||
|
if e.TxHash != nil {
|
||||||
|
txLink = fmt.Sprintf(
|
||||||
|
`<a href="https://etherscan.io/tx/%s" style="color:#0066cc;">%s…</a>`,
|
||||||
|
*e.TxHash, (*e.TxHash)[:10],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows += fmt.Sprintf(`
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee;">%s</td>
|
||||||
|
<td style="padding:6px 8px; border-bottom:1px solid #eee; font-size:12px; color:#666;">%s</td>
|
||||||
|
</tr>`,
|
||||||
|
label, e.Message, txLink,
|
||||||
|
e.Timestamp.Format("Jan 2 15:04 UTC"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 700px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Koin Ping — Alert Digest</h2>
|
||||||
|
<p>Here are your recent alerts (%d total):</p>
|
||||||
|
<table style="width:100%%; border-collapse:collapse; font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f5f5f5;">
|
||||||
|
<th style="padding:8px; text-align:left;">Address</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Alert</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Tx</th>
|
||||||
|
<th style="padding:8px; text-align:left;">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>%s</tbody>
|
||||||
|
</table>
|
||||||
|
<hr style="border:none; border-top:1px solid #eee; margin:24px 0;" />
|
||||||
|
<p style="font-size:12px; color:#999;">Sent by Koin Ping</p>
|
||||||
|
</div>`, len(events), rows)
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Koin Ping Digest — %d alerts", len(events))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := s.notifCfgs.ListEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list enabled configs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if cfg.Email == nil || *cfg.Email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.SendDigest(ctx, cfg.UserID, *cfg.Email); err != nil {
|
||||||
|
log.Printf("Failed to send digest to user %s: %v", cfg.UserID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resendEmailPayload struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailDigestService) send(to, subject, html string) error {
|
||||||
|
payload := resendEmailPayload{
|
||||||
|
From: s.fromAddress,
|
||||||
|
To: to,
|
||||||
|
Subject: subject,
|
||||||
|
HTML: html,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal email payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, resendAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.apiKey)
|
||||||
|
|
||||||
|
resp, err := digestHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("send email via Resend: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("Resend API returned HTTP %d", resp.StatusCode) //nolint:err113
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
"github.com/kjannette/koin-ping/backend-go/internal/domain"
|
||||||
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
"github.com/kjannette/koin-ping/backend-go/internal/models"
|
||||||
@@ -12,12 +13,20 @@ import (
|
|||||||
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
"github.com/kjannette/koin-ping/backend-go/internal/wei"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
notificationTimeout = 30 * time.Second
|
||||||
|
notificationMaxRetries = 3
|
||||||
|
notificationRetryBase = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type EvaluatorService struct {
|
type EvaluatorService struct {
|
||||||
eth ethereum.EthereumObserver
|
eth ethereum.EthereumObserver
|
||||||
alertRules *models.AlertRuleModel
|
alertRules *models.AlertRuleModel
|
||||||
alertEvents *models.AlertEventModel
|
alertEvents *models.AlertEventModel
|
||||||
addresses *models.AddressModel
|
addresses *models.AddressModel
|
||||||
notifConfigs *models.NotificationConfigModel
|
notifConfigs *models.NotificationConfigModel
|
||||||
|
resendAPIKey string
|
||||||
|
emailFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvaluatorService(
|
func NewEvaluatorService(
|
||||||
@@ -26,6 +35,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents *models.AlertEventModel,
|
alertEvents *models.AlertEventModel,
|
||||||
addresses *models.AddressModel,
|
addresses *models.AddressModel,
|
||||||
notifConfigs *models.NotificationConfigModel,
|
notifConfigs *models.NotificationConfigModel,
|
||||||
|
resendAPIKey string,
|
||||||
|
emailFrom string,
|
||||||
) *EvaluatorService {
|
) *EvaluatorService {
|
||||||
return &EvaluatorService{
|
return &EvaluatorService{
|
||||||
eth: eth,
|
eth: eth,
|
||||||
@@ -33,6 +44,8 @@ func NewEvaluatorService(
|
|||||||
alertEvents: alertEvents,
|
alertEvents: alertEvents,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
notifConfigs: notifConfigs,
|
notifConfigs: notifConfigs,
|
||||||
|
resendAPIKey: resendAPIKey,
|
||||||
|
emailFrom: emailFrom,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,25 +174,85 @@ func (s *EvaluatorService) fireAlert(ctx context.Context, rule domain.AlertRule,
|
|||||||
message := s.buildMessage(rule, obs)
|
message := s.buildMessage(rule, obs)
|
||||||
txHash := &obs.Hash
|
txHash := &obs.Hash
|
||||||
|
|
||||||
_, err = s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
event, err := s.alertEvents.Create(ctx, rule.ID, message, &addressLabel, txHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event == nil {
|
||||||
|
log.Printf("[ALERT DEDUP] Rule %d (%s) - duplicate event skipped for TX: %s", rule.ID, rule.Type, obs.Hash)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
log.Printf("[ALERT FIRED] Rule %d (%s) - %s - TX: %s", rule.ID, rule.Type, message, obs.Hash)
|
||||||
|
|
||||||
// Send Discord notification (non-fatal on failure)
|
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
|
userID := addr.UserID
|
||||||
|
address := addr.Address
|
||||||
go func() {
|
go func() {
|
||||||
s.sendNotification(
|
notifCtx, cancel := context.WithTimeout(context.Background(), notificationTimeout)
|
||||||
ctx, addr.UserID, message, obs, addressLabel, rule, addr.Address,
|
defer cancel()
|
||||||
)
|
s.sendNotification(notifCtx, userID, message, obs, addressLabel, rule, address)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *EvaluatorService) buildNotifiers(cfg *domain.NotificationConfig) []notifications.Notifier {
|
||||||
|
var notifiers []notifications.Notifier
|
||||||
|
|
||||||
|
if cfg.DiscordWebhookURL != nil && *cfg.DiscordWebhookURL != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
|
||||||
|
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.TelegramNotifier{
|
||||||
|
BotToken: *cfg.TelegramBotToken,
|
||||||
|
ChatID: *cfg.TelegramChatID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Email != nil && *cfg.Email != "" {
|
||||||
|
notifiers = append(notifiers, ¬ifications.EmailNotifier{
|
||||||
|
APIKey: s.resendAPIKey,
|
||||||
|
From: s.emailFrom,
|
||||||
|
To: *cfg.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendWithRetry(ctx context.Context, n notifications.Notifier, message string, meta notifications.AlertMetadata) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := range notificationMaxRetries {
|
||||||
|
if attempt > 0 {
|
||||||
|
wait := notificationRetryBase * time.Duration(1<<(attempt-1))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.Send(ctx, message, meta); err != nil {
|
||||||
|
log.Printf("Notification attempt %d/%d failed: %v", attempt+1, notificationMaxRetries, err)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message string, obs domain.ObservedTx, addressLabel string, rule domain.AlertRule, address string) {
|
||||||
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
notifConfig, err := s.notifConfigs.GetConfig(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,29 +260,31 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if notifConfig == nil || !notifConfig.NotificationEnabled || notifConfig.DiscordWebhookURL == nil {
|
if notifConfig == nil || !notifConfig.NotificationEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sent, err := notifications.SendDiscordNotification(
|
meta := notifications.AlertMetadata{
|
||||||
*notifConfig.DiscordWebhookURL,
|
TxHash: obs.Hash,
|
||||||
message,
|
AddressLabel: addressLabel,
|
||||||
notifications.AlertMetadata{
|
AlertType: string(rule.Type),
|
||||||
TxHash: obs.Hash,
|
Address: address,
|
||||||
AddressLabel: addressLabel,
|
}
|
||||||
AlertType: string(rule.Type),
|
|
||||||
Address: address,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil || !sent {
|
for _, n := range s.buildNotifiers(notifConfig) {
|
||||||
log.Printf("Discord notification failed for user %s: %v", userID, err)
|
if err := sendWithRetry(ctx, n, message, meta); err != nil {
|
||||||
} else {
|
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
|
||||||
log.Printf("Discord notification sent to user %s", userID)
|
} else {
|
||||||
|
log.Printf("Notification sent to user %s via %T", userID, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string {
|
||||||
|
if obs.IsTokenTransfer() {
|
||||||
|
return s.buildTokenMessage(rule, obs)
|
||||||
|
}
|
||||||
|
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case domain.AlertIncomingTx:
|
case domain.AlertIncomingTx:
|
||||||
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
ethStr, _ := wei.FormatAsEth(obs.Value, 4)
|
||||||
@@ -234,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ
|
|||||||
return "Alert triggered"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%.*f ETH", decimals, eth), nil
|
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,75 +1,36 @@
|
|||||||
import { Routes, Route, Link, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./contexts/AuthContext";
|
import { useAuth } from "./contexts/AuthContext";
|
||||||
|
import Navbar from "./components/Navbar";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Signup from "./pages/Signup";
|
import Signup from "./pages/Signup";
|
||||||
|
import Onboarding from "./pages/Onboarding";
|
||||||
import Addresses from "./pages/Addresses";
|
import Addresses from "./pages/Addresses";
|
||||||
import Alerts from "./pages/Alerts";
|
import Alerts from "./pages/Alerts";
|
||||||
import AlertHistory from "./pages/AlertHistory";
|
import AlertHistory from "./pages/AlertHistory";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
// Show login/signup routes if not authenticated
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show main app if authenticated
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "1rem" }}>
|
<div>
|
||||||
<nav
|
<Navbar />
|
||||||
style={{
|
|
||||||
marginBottom: "1rem",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Link to="/addresses">Addresses</Link>
|
|
||||||
{" | "}
|
|
||||||
<Link to="/alerts">Alerts</Link>
|
|
||||||
{" | "}
|
|
||||||
<Link to="/history">History</Link>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.9rem", color: "#999" }}>
|
|
||||||
{currentUser.email}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
color: "white",
|
|
||||||
border: "1px solid #555",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Addresses />} />
|
<Route path="/" element={<Addresses />} />
|
||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/history" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,6 +80,41 @@ export async function getAddresses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an address (e.g. change its label)
|
||||||
|
* @param {number} addressId - Address ID to update
|
||||||
|
* @param {Object} data - Fields to update (e.g. { label: "New Label" })
|
||||||
|
* @returns {Promise<Object>} Updated address
|
||||||
|
*/
|
||||||
|
export async function updateAddress(addressId, data) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/addresses/${addressId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to update address";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error("Cannot connect to server. Is the backend running?");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a tracked address
|
* Delete a tracked address
|
||||||
* @param {number} addressId - Address ID to delete
|
* @param {number} addressId - Address ID to delete
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// API client for notification configuration
|
|
||||||
|
|
||||||
import { getAuthHeaders } from "./authHeaders";
|
import { getAuthHeaders } from "./authHeaders";
|
||||||
import { API_BASE } from "./config";
|
import { API_BASE } from "./config";
|
||||||
|
|
||||||
@@ -38,11 +36,13 @@ export async function getNotificationConfig() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update notification configuration
|
* Update notification configuration
|
||||||
* @param {Object} config - Configuration to update
|
* @param {Object} config
|
||||||
* @param {string} [config.discord_webhook_url] - Discord webhook URL
|
* @param {string} [config.discord_webhook_url]
|
||||||
* @param {string} [config.telegram_chat_id] - Telegram chat ID
|
* @param {string} [config.telegram_chat_id]
|
||||||
* @param {string} [config.email] - Email address
|
* @param {string} [config.telegram_bot_token]
|
||||||
* @param {boolean} [config.notification_enabled] - Enable/disable notifications
|
* @param {string} [config.email]
|
||||||
|
* @param {string} [config.slack_webhook_url]
|
||||||
|
* @param {boolean} [config.notification_enabled]
|
||||||
* @returns {Promise<Object>} Updated config
|
* @returns {Promise<Object>} Updated config
|
||||||
*/
|
*/
|
||||||
export async function updateNotificationConfig(config) {
|
export async function updateNotificationConfig(config) {
|
||||||
@@ -77,27 +77,107 @@ export async function updateNotificationConfig(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test a Discord webhook URL
|
* Set up email notifications via Resend.
|
||||||
* Sends a test message to verify the webhook works
|
* Reads the user's saved email from their config and sends a confirmation.
|
||||||
* @param {string} webhookUrl - Discord webhook URL to test
|
* @returns {Promise<Object>} Setup result
|
||||||
* @returns {Promise<boolean>} True if test successful
|
|
||||||
*/
|
*/
|
||||||
export async function testDiscordWebhook(webhookUrl) {
|
export async function setupEmail() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const headers = await getAuthHeaders();
|
||||||
content:
|
const response = await fetch(`${API_BASE}/email/setup`, {
|
||||||
"Koin Ping test notification - Your Discord webhook is configured correctly!",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(webhookUrl, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: headers,
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.ok;
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to set up email";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Discord webhook test failed:", error);
|
if (error.message.includes("fetch")) {
|
||||||
return false;
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an alert digest email to the user's configured email address.
|
||||||
|
* @returns {Promise<Object>} Digest send result
|
||||||
|
*/
|
||||||
|
export async function sendEmailDigest() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(`${API_BASE}/email/digest`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to send digest";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test all configured notification channels via the backend
|
||||||
|
* @returns {Promise<Object>} Results per channel
|
||||||
|
*/
|
||||||
|
export async function testNotificationChannels() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/notification-config/test`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = "Failed to test notification channels";
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
errorMessage = error.message || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("fetch")) {
|
||||||
|
throw new Error(
|
||||||
|
"Cannot connect to server. Is the backend running?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
frontend/src/components/Navbar.jsx
Normal file
85
frontend/src/components/Navbar.jsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: "/addresses", label: "Addresses" },
|
||||||
|
{ to: "/alerts", label: "Configure Alerts" },
|
||||||
|
{ to: "/alertevents", label: "Alert Events" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { currentUser, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
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.4rem",
|
||||||
|
color: "#e62525",
|
||||||
|
marginRight: "2rem",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
}}>
|
||||||
|
Koin Ping
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
|
{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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/index.css
Normal file
33
frontend/src/index.css
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap');
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 200;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea, button {
|
||||||
|
font-family: 'Nunito', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AddressForm from "../components/AddressForm";
|
import AddressForm from "../components/AddressForm";
|
||||||
import { getAddresses, createAddress } from "../api/addresses";
|
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../api/addresses";
|
||||||
|
|
||||||
export default function Addresses() {
|
export default function Addresses() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
|
||||||
// Load addresses on mount
|
// Load addresses on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,15 +31,52 @@ export default function Addresses() {
|
|||||||
async function handleAddressSubmit(data) {
|
async function handleAddressSubmit(data) {
|
||||||
try {
|
try {
|
||||||
const newAddress = await createAddress(data);
|
const newAddress = await createAddress(data);
|
||||||
// Append new address to state
|
|
||||||
setAddresses((prev) => [...prev, newAddress]);
|
setAddresses((prev) => [...prev, newAddress]);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create address:", err);
|
console.error("Failed to create address:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(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 (
|
return (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1>Tracked Addresses</h1>
|
<h1>Tracked Addresses</h1>
|
||||||
@@ -51,7 +90,7 @@ export default function Addresses() {
|
|||||||
{loading && <p>Loading addresses...</p>}
|
{loading && <p>Loading addresses...</p>}
|
||||||
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
{error && <p style={{ color: "red" }}>Error: {error}</p>}
|
||||||
{!loading && !error && addresses.length === 0 && (
|
{!loading && !error && addresses.length === 0 && (
|
||||||
<p style={{ color: "#666" }}>
|
<p style={{ color: "#808080" }}>
|
||||||
No addresses tracked yet. Add one above to get started.
|
No addresses tracked yet. Add one above to get started.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -63,25 +102,93 @@ export default function Addresses() {
|
|||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #444",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "#333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
style={{
|
<div style={{ flex: 1 }}>
|
||||||
fontWeight: "bold",
|
{editingId === addr.id ? (
|
||||||
marginBottom: "0.25rem",
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
|
||||||
}}
|
<input
|
||||||
>
|
value={editLabel}
|
||||||
{addr.label || "Unlabeled"}
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
</div>
|
placeholder="Label (optional)"
|
||||||
<div
|
style={{
|
||||||
style={{
|
background: "#444",
|
||||||
fontFamily: "monospace",
|
border: "1px solid #666",
|
||||||
fontSize: "0.9rem",
|
borderRadius: "3px",
|
||||||
}}
|
color: "#fff",
|
||||||
>
|
padding: "0.25rem 0.5rem",
|
||||||
{addr.address}
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export default function AlertHistory() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
|
||||||
<h1>Recent Alerts</h1>
|
<h1>Recent Alert Events</h1>
|
||||||
|
|
||||||
{alertEvents.length === 0 ? (
|
{alertEvents.length === 0 ? (
|
||||||
<p style={{ color: "#666" }}>No alerts yet</p>
|
<p style={{ color: "#808080" }}>No alerts yet</p>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
{alertEvents.map((event) => (
|
{alertEvents.map((event) => (
|
||||||
@@ -48,8 +48,9 @@ export default function AlertHistory() {
|
|||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
marginBottom: "0.75rem",
|
marginBottom: "0.75rem",
|
||||||
border: "1px solid #ddd",
|
border: "1px solid #444",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "#333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: "0.5rem" }}>
|
<div style={{ marginBottom: "0.5rem" }}>
|
||||||
@@ -58,15 +59,15 @@ export default function AlertHistory() {
|
|||||||
{event.address_label && (
|
{event.address_label && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.9rem",
|
fontSize: "1.035rem",
|
||||||
color: "#666",
|
color: "#808080",
|
||||||
marginBottom: "0.25rem",
|
marginBottom: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Address: {event.address_label}
|
Address: {event.address_label}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<small style={{ color: "#999" }}>
|
<small style={{ color: "#b3b3b3" }}>
|
||||||
{formatTimestamp(event.timestamp)}
|
{formatTimestamp(event.timestamp)}
|
||||||
</small>
|
</small>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import AlertForm from "../components/AlertForm";
|
import AlertForm from "../components/AlertForm";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import Input from "../components/Input";
|
|
||||||
import { getAddresses } from "../api/addresses";
|
import { getAddresses } from "../api/addresses";
|
||||||
import {
|
import {
|
||||||
getAlerts,
|
getAlerts,
|
||||||
@@ -12,9 +11,40 @@ import {
|
|||||||
import {
|
import {
|
||||||
getNotificationConfig,
|
getNotificationConfig,
|
||||||
updateNotificationConfig,
|
updateNotificationConfig,
|
||||||
testDiscordWebhook,
|
testNotificationChannels,
|
||||||
|
setupEmail,
|
||||||
|
sendEmailDigest,
|
||||||
} from "../api/notificationConfig";
|
} 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() {
|
export default function Alerts() {
|
||||||
const [addresses, setAddresses] = useState([]);
|
const [addresses, setAddresses] = useState([]);
|
||||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||||
@@ -23,34 +53,48 @@ export default function Alerts() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Notification config state
|
// Notification config state
|
||||||
const [notificationConfig, setNotificationConfig] = useState(null);
|
const [notificationEnabled, setNotificationEnabled] = useState(false);
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
const [discordWebhookUrl, setDiscordWebhookUrl] = useState("");
|
||||||
const [notificationEnabled, setNotificationEnabled] = useState(true);
|
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||||
|
const [telegramChatId, setTelegramChatId] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [slackWebhookUrl, setSlackWebhookUrl] = useState("");
|
||||||
const [notificationLoading, setNotificationLoading] = useState(false);
|
const [notificationLoading, setNotificationLoading] = useState(false);
|
||||||
const [notificationError, setNotificationError] = useState(null);
|
const [notificationError, setNotificationError] = useState(null);
|
||||||
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
const [notificationSuccess, setNotificationSuccess] = useState(null);
|
||||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
const [testingChannels, setTestingChannels] = useState(false);
|
||||||
|
const [settingUpEmail, setSettingUpEmail] = useState(false);
|
||||||
|
const [sendingDigest, setSendingDigest] = useState(false);
|
||||||
|
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||||
|
|
||||||
// Load addresses and notification config on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Fetch addresses
|
|
||||||
const addressData = await getAddresses();
|
const addressData = await getAddresses();
|
||||||
setAddresses(addressData);
|
setAddresses(addressData);
|
||||||
if (addressData.length > 0) {
|
if (addressData.length > 0) {
|
||||||
setSelectedAddressId(addressData[0].id);
|
setSelectedAddressId(addressData[0].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch notification config
|
|
||||||
const configData = await getNotificationConfig();
|
const configData = await getNotificationConfig();
|
||||||
setNotificationConfig(configData);
|
|
||||||
setDiscordWebhookUrl(configData.discord_webhook_url || "");
|
|
||||||
setNotificationEnabled(
|
setNotificationEnabled(
|
||||||
configData.notification_enabled !== false,
|
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) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch data:", err);
|
console.error("Failed to fetch data:", err);
|
||||||
@@ -62,7 +106,6 @@ export default function Alerts() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load alerts when address is selected
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAddressId) {
|
if (!selectedAddressId) {
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
@@ -73,7 +116,7 @@ export default function Alerts() {
|
|||||||
try {
|
try {
|
||||||
const data = await getAlerts(selectedAddressId);
|
const data = await getAlerts(selectedAddressId);
|
||||||
setAlerts(data);
|
setAlerts(data);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to fetch alerts:", err);
|
console.error("Failed to fetch alerts:", err);
|
||||||
@@ -83,63 +126,68 @@ export default function Alerts() {
|
|||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
}, [selectedAddressId]);
|
}, [selectedAddressId]);
|
||||||
|
|
||||||
// Handle new alert submission
|
|
||||||
async function handleAlertSubmit(data) {
|
async function handleAlertSubmit(data) {
|
||||||
if (!selectedAddressId) return;
|
if (!selectedAddressId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newAlert = await createAlert(selectedAddressId, data);
|
const newAlert = await createAlert(selectedAddressId, data);
|
||||||
setAlerts((prev) => [...prev, newAlert]);
|
setAlerts((prev) => [...prev, newAlert]);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to create alert:", err);
|
console.error("Failed to create alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle alert enabled/disabled
|
|
||||||
async function handleToggleAlert(alertId, currentStatus) {
|
async function handleToggleAlert(alertId, currentStatus) {
|
||||||
try {
|
try {
|
||||||
const updated = await updateAlertStatus(alertId, !currentStatus);
|
const updated = await updateAlertStatus(alertId, !currentStatus);
|
||||||
setAlerts((prev) =>
|
setAlerts((prev) =>
|
||||||
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
prev.map((alert) => (alert.id === alertId ? updated : alert)),
|
||||||
);
|
);
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to update alert:", err);
|
console.error("Failed to update alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete alert
|
|
||||||
async function handleDeleteAlert(alertId) {
|
async function handleDeleteAlert(alertId) {
|
||||||
try {
|
try {
|
||||||
await deleteAlert(alertId);
|
await deleteAlert(alertId);
|
||||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||||
setError(null); // Clear any previous errors
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
console.error("Failed to delete alert:", err);
|
console.error("Failed to delete alert:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save notification config
|
|
||||||
async function handleSaveNotificationConfig() {
|
async function handleSaveNotificationConfig() {
|
||||||
|
if (hasExistingConfig) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"This will overwrite your previously saved notification settings. Continue?",
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setNotificationLoading(true);
|
setNotificationLoading(true);
|
||||||
setNotificationError(null);
|
setNotificationError(null);
|
||||||
setNotificationSuccess(null);
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
discord_webhook_url: discordWebhookUrl || null,
|
|
||||||
notification_enabled: notificationEnabled,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updated = await updateNotificationConfig(config);
|
await updateNotificationConfig(config);
|
||||||
setNotificationConfig(updated);
|
setHasExistingConfig(true);
|
||||||
setNotificationSuccess("Notification settings saved!");
|
setNotificationSuccess("Notification settings saved!");
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
setTimeout(() => setNotificationSuccess(null), 3000);
|
setTimeout(() => setNotificationSuccess(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError(err.message);
|
setNotificationError(err.message);
|
||||||
@@ -149,32 +197,76 @@ export default function Alerts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test Discord webhook
|
async function handleTestChannels() {
|
||||||
async function handleTestWebhook() {
|
|
||||||
if (!discordWebhookUrl) {
|
|
||||||
setNotificationError("Please enter a Discord webhook URL first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestingWebhook(true);
|
setTestingChannels(true);
|
||||||
setNotificationError(null);
|
setNotificationError(null);
|
||||||
setNotificationSuccess(null);
|
setNotificationSuccess(null);
|
||||||
|
|
||||||
const success = await testDiscordWebhook(discordWebhookUrl);
|
const data = await testNotificationChannels();
|
||||||
|
const results = data.results || [];
|
||||||
|
|
||||||
if (success) {
|
const failed = results.filter((r) => !r.success);
|
||||||
|
const succeeded = results.filter((r) => r.success);
|
||||||
|
|
||||||
|
if (failed.length === 0 && succeeded.length > 0) {
|
||||||
setNotificationSuccess(
|
setNotificationSuccess(
|
||||||
"Test notification sent! Check your Discord channel.",
|
`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), 5000);
|
|
||||||
} else {
|
|
||||||
setNotificationError("Test failed. Check your webhook URL.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotificationSuccess(null);
|
||||||
|
setNotificationError(null);
|
||||||
|
}, 6000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotificationError("Test failed: " + err.message);
|
setNotificationError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingWebhook(false);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,19 +293,18 @@ export default function Alerts() {
|
|||||||
Alert Rules & Notifications
|
Alert Rules & Notifications
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "2rem",
|
gap: "2rem",
|
||||||
|
alignItems: "start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* LEFT COLUMN: Alert Rules */}
|
{/* LEFT COLUMN: Alert Rules */}
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
<h2 style={{ marginTop: 0 }}>Alert Rules</h2>
|
||||||
|
|
||||||
{/* Address selector */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<label
|
<label
|
||||||
style={{ display: "block", marginBottom: "0.5rem" }}
|
style={{ display: "block", marginBottom: "0.5rem" }}
|
||||||
@@ -245,20 +336,19 @@ export default function Alerts() {
|
|||||||
|
|
||||||
{selectedAddress && (
|
{selectedAddress && (
|
||||||
<>
|
<>
|
||||||
{/* Current address info */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
marginBottom: "2rem",
|
marginBottom: "2rem",
|
||||||
backgroundColor: "#2a2a2a",
|
backgroundColor: "#333",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
border: "1px solid #444",
|
border: "1px solid #444",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.9rem",
|
fontSize: "1.035rem",
|
||||||
color: "#999",
|
color: "#b3b3b3",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Managing alerts for:
|
Managing alerts for:
|
||||||
@@ -274,21 +364,19 @@ export default function Alerts() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
fontSize: "0.9rem",
|
fontSize: "1.035rem",
|
||||||
color: "#999",
|
color: "#b3b3b3",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedAddress.address}
|
{selectedAddress.address}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert creation form */}
|
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div style={{ marginBottom: "2rem" }}>
|
||||||
<h3>Create New Alert</h3>
|
<h3>Create New Alert</h3>
|
||||||
<AlertForm onSubmit={handleAlertSubmit} />
|
<AlertForm onSubmit={handleAlertSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing alerts list */}
|
|
||||||
<div>
|
<div>
|
||||||
<h3>Active Alert Rules</h3>
|
<h3>Active Alert Rules</h3>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -316,7 +404,7 @@ export default function Alerts() {
|
|||||||
marginBottom: "0.5rem",
|
marginBottom: "0.5rem",
|
||||||
border: "1px solid #444",
|
border: "1px solid #444",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
backgroundColor: "#2a2a2a",
|
backgroundColor: "#333",
|
||||||
opacity: alert.enabled
|
opacity: alert.enabled
|
||||||
? 1
|
? 1
|
||||||
: 0.6,
|
: 0.6,
|
||||||
@@ -348,8 +436,8 @@ export default function Alerts() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize:
|
fontSize:
|
||||||
"0.9rem",
|
"1.035rem",
|
||||||
color: "#999",
|
color: "#b3b3b3",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Threshold:{" "}
|
Threshold:{" "}
|
||||||
@@ -362,8 +450,8 @@ export default function Alerts() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize:
|
fontSize:
|
||||||
"0.85rem",
|
"0.978rem",
|
||||||
color: "#666",
|
color: "#808080",
|
||||||
marginTop:
|
marginTop:
|
||||||
"0.25rem",
|
"0.25rem",
|
||||||
}}
|
}}
|
||||||
@@ -449,9 +537,9 @@ export default function Alerts() {
|
|||||||
{/* Master toggle */}
|
{/* Master toggle */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: "2rem",
|
marginBottom: "1.5rem",
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
backgroundColor: "#f5f5f5",
|
backgroundColor: "#333",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -480,8 +568,8 @@ export default function Alerts() {
|
|||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.978rem",
|
||||||
color: "#666",
|
color: "#808080",
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
marginLeft: "26px",
|
marginLeft: "26px",
|
||||||
}}
|
}}
|
||||||
@@ -490,57 +578,208 @@ export default function Alerts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discord Section */}
|
{/* All channel settings — hidden when master toggle is off */}
|
||||||
<div style={{ marginBottom: "2rem" }}>
|
<div
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Discord</h3>
|
style={{
|
||||||
|
display: notificationEnabled ? "block" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Telegram */}
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
|
Telegram
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
<label
|
<label style={labelStyle}>Bot Token</label>
|
||||||
style={{
|
<input
|
||||||
display: "block",
|
type="text"
|
||||||
marginBottom: "0.5rem",
|
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}
|
||||||
>
|
>
|
||||||
Discord Webhook URL
|
How to create a Telegram bot & get your Chat ID
|
||||||
</label>
|
</a>
|
||||||
<input
|
</div>
|
||||||
type="text"
|
|
||||||
value={discordWebhookUrl}
|
{/* Email */}
|
||||||
onChange={(e) =>
|
<div style={sectionStyle}>
|
||||||
setDiscordWebhookUrl(e.target.value)
|
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
|
||||||
}
|
Email
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
</h3>
|
||||||
style={{
|
|
||||||
width: "100%",
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
padding: "0.5rem",
|
<label style={labelStyle}>Email Address</label>
|
||||||
fontSize: "1rem",
|
<input
|
||||||
backgroundColor: "#1a1a1a",
|
type="email"
|
||||||
border: "1px solid #444",
|
value={email}
|
||||||
borderRadius: "4px",
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
color: "white",
|
placeholder="you@example.com"
|
||||||
fontFamily: "monospace",
|
style={inputStyle}
|
||||||
fontSize: "0.9rem",
|
/>
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.85rem",
|
fontSize: "0.85rem",
|
||||||
color: "#999",
|
color: "#808080",
|
||||||
marginTop: "0.5rem",
|
marginBottom: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<a
|
Alert notifications and digests will be sent to
|
||||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
this address
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: "#0066cc" }}
|
<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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
How to get a Discord webhook URL
|
{settingUpEmail
|
||||||
</a>
|
? "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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
{/* 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
|
<button
|
||||||
onClick={handleSaveNotificationConfig}
|
onClick={handleSaveNotificationConfig}
|
||||||
disabled={notificationLoading}
|
disabled={notificationLoading}
|
||||||
@@ -564,67 +803,34 @@ export default function Alerts() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleTestWebhook}
|
onClick={handleTestChannels}
|
||||||
disabled={testingWebhook || !discordWebhookUrl}
|
disabled={testingChannels}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem 1.5rem",
|
padding: "0.75rem 1.5rem",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
backgroundColor:
|
backgroundColor: testingChannels
|
||||||
testingWebhook || !discordWebhookUrl
|
? "#333"
|
||||||
? "#333"
|
: "#28a745",
|
||||||
: "#28a745",
|
|
||||||
color: "white",
|
color: "white",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
cursor:
|
cursor: testingChannels
|
||||||
testingWebhook || !discordWebhookUrl
|
? "not-allowed"
|
||||||
? "not-allowed"
|
: "pointer",
|
||||||
: "pointer",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{testingWebhook ? "Testing..." : "Test Webhook"}
|
{testingChannels
|
||||||
|
? "Testing..."
|
||||||
|
: "Test All Channels"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telegram Section (Coming Soon) */}
|
|
||||||
<div style={{ marginBottom: "2rem", opacity: 0.5 }}>
|
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Telegram</h3>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#999",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Section (Coming Soon) */}
|
|
||||||
<div style={{ opacity: 0.5 }}>
|
|
||||||
<h3 style={{ marginBottom: "1rem" }}>Email</h3>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
backgroundColor: "#333",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#999",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format alert type for display
|
|
||||||
function formatAlertType(type) {
|
function formatAlertType(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
incoming_tx: "Incoming transaction",
|
incoming_tx: "Incoming transaction",
|
||||||
|
|||||||
88
frontend/src/pages/Login.css
Normal file
88
frontend/src/pages/Login.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
.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 #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-heading {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
color: #e62525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: #ff000020;
|
||||||
|
border: 1px solid #ff0000;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field-last {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background-color: #242424;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:disabled {
|
||||||
|
background-color: #333;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer-text {
|
||||||
|
color: #b3b3b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-signup-link {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
@@ -1,146 +1,107 @@
|
|||||||
/**
|
|
||||||
* Login Page
|
|
||||||
*
|
|
||||||
* Allows existing users to sign in with email and password
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import "./Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [password, setPassword] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
setError("Please fill in all fields");
|
setError("Please fill in all fields");
|
||||||
return;
|
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 (
|
try {
|
||||||
<div
|
setError("");
|
||||||
style={{
|
setLoading(true);
|
||||||
maxWidth: "400px",
|
await login(email, password);
|
||||||
margin: "4rem auto",
|
navigate("/addresses"); // Redirect to main app
|
||||||
padding: "2rem",
|
} catch (err) {
|
||||||
border: "1px solid #333",
|
setError("Failed to log in: " + err.message);
|
||||||
borderRadius: "8px",
|
} finally {
|
||||||
}}
|
setLoading(false);
|
||||||
>
|
}
|
||||||
<h1 style={{ marginBottom: "2rem", textAlign: "center" }}>
|
}
|
||||||
Koin Ping - Login
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{error && (
|
return (
|
||||||
<div
|
<div className="login-page">
|
||||||
style={{
|
<div
|
||||||
padding: "0.75rem",
|
className="login-card"
|
||||||
marginBottom: "1rem",
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
backgroundColor: "#ff000020",
|
>
|
||||||
border: "1px solid #ff0000",
|
<h1 className="login-heading">
|
||||||
borderRadius: "4px",
|
<span className="login-brand">Koin Ping</span> - Login
|
||||||
color: "#ff6666",
|
</h1>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div style={{ visibility: isVisible ? 'visible' : 'hidden' }}>
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
{error && (
|
||||||
<label style={{ display: "block", marginBottom: "0.5rem" }}>
|
<div className="login-error">
|
||||||
Email
|
{error}
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
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: "#1a1a1a",
|
|
||||||
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: "#999" }}>
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link
|
|
||||||
to="/signup"
|
|
||||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
Sign up here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="login-field">
|
||||||
|
<label className="login-label">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="login-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-field-last">
|
||||||
|
<label className="login-label">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="login-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="login-button"
|
||||||
|
>
|
||||||
|
{loading ? "Logging in..." : "Log In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
<p className="login-footer-text">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="login-signup-link"
|
||||||
|
>
|
||||||
|
Sign up here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
834
frontend/src/pages/Onboarding.jsx
Normal file
834
frontend/src/pages/Onboarding.jsx
Normal file
@@ -0,0 +1,834 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding Wizard
|
||||||
|
*
|
||||||
|
* 5-step guided flow: Create Account → Add Wallet → Alert Rules → Notifications → Done
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } 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";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"Create Account",
|
||||||
|
"Add Wallet",
|
||||||
|
"Alert Rules",
|
||||||
|
"Notifications",
|
||||||
|
"Done",
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "white",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "0.4rem",
|
||||||
|
color: "#ccc",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Onboarding() {
|
||||||
|
const { currentUser, signup } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Wizard state
|
||||||
|
const [data, setData] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
walletAddress: "",
|
||||||
|
walletLabel: "",
|
||||||
|
createdAddressId: null,
|
||||||
|
alertIncomingTx: false,
|
||||||
|
alertOutgoingTx: false,
|
||||||
|
alertLargeTransfer: false,
|
||||||
|
largeTransferThreshold: "",
|
||||||
|
alertBalanceBelow: false,
|
||||||
|
balanceBelowThreshold: "",
|
||||||
|
discordWebhookUrl: "",
|
||||||
|
slackWebhookUrl: "",
|
||||||
|
notificationEmail: "",
|
||||||
|
// summary
|
||||||
|
alertsCreated: [],
|
||||||
|
notificationConfigured: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function set(field, value) {
|
||||||
|
setData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mount: if already fully onboarded, redirect away
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
getAddresses()
|
||||||
|
.then((addresses) => {
|
||||||
|
if (addresses.length > 0) {
|
||||||
|
navigate("/addresses", { replace: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {}); // ignore errors (e.g. mid-signup)
|
||||||
|
}, [currentUser, navigate]);
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
// If user already exists (browser-close-mid-wizard), skip signup
|
||||||
|
if (!currentUser) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await signup(data.email, data.password);
|
||||||
|
} 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);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STEPS.map((label, i) => {
|
||||||
|
const stepNum = i + 1;
|
||||||
|
const done = step > stepNum;
|
||||||
|
const active = step === stepNum;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
style={{ display: "flex", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{i > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "2px",
|
||||||
|
backgroundColor: done || active ? "#0066cc" : "#444",
|
||||||
|
margin: "0 4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor:
|
||||||
|
done ? "#0066cc" : active ? "#0066cc" : "#333",
|
||||||
|
border: active ? "2px solid #4499ff" : "2px solid transparent",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "white",
|
||||||
|
margin: "0 auto 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? "✓" : stepNum}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
color: active ? "white" : "#888",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step content ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Step1() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginBottom: "1.5rem" }}>Create your account</h2>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={labelStyle}>Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(e) => set("email", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={labelStyle}>Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={(e) => set("password", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<label style={labelStyle}>Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={data.confirmPassword}
|
||||||
|
onChange={(e) => set("confirmPassword", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginBottom: "0.5rem" }}>Add a wallet address</h2>
|
||||||
|
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||||
|
Enter the Ethereum address you want to monitor.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={labelStyle}>ETH Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.walletAddress}
|
||||||
|
onChange={(e) => set("walletAddress", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="0x..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<label style={labelStyle}>Label (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.walletLabel}
|
||||||
|
onChange={(e) => set("walletLabel", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g. My main wallet"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginBottom: "0.5rem" }}>Configure alert rules</h2>
|
||||||
|
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||||
|
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 style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={data.largeTransferThreshold}
|
||||||
|
onChange={(e) => set("largeTransferThreshold", e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: "160px" }}
|
||||||
|
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 style={{ marginTop: "0.5rem", marginLeft: "1.75rem" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={data.balanceBelowThreshold}
|
||||||
|
onChange={(e) => set("balanceBelowThreshold", e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: "160px" }}
|
||||||
|
placeholder="Threshold (ETH)"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CheckboxRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step4() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginBottom: "0.5rem" }}>Set up notifications</h2>
|
||||||
|
<p style={{ color: "#aaa", marginBottom: "1.5rem", fontSize: "0.9rem" }}>
|
||||||
|
Add at least one channel so you receive alerts. All fields are optional.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
Discord Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={data.discordWebhookUrl}
|
||||||
|
onChange={(e) => set("discordWebhookUrl", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.25rem" }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
Slack Webhook URL{" "}
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/messaging/webhooks"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: "#4499ff", fontSize: "0.8rem" }}
|
||||||
|
>
|
||||||
|
(how to get one)
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={data.slackWebhookUrl}
|
||||||
|
onChange={(e) => set("slackWebhookUrl", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<label style={labelStyle}>Email address for alerts</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={data.notificationEmail}
|
||||||
|
onChange={(e) => set("notificationEmail", e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step5() {
|
||||||
|
const alertCount = data.alertsCreated.length;
|
||||||
|
const hasNotif = data.notificationConfigured;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 style={{ marginBottom: "1rem" }}>You're all set!</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1e2e1e",
|
||||||
|
border: "1px solid #2d5a2d",
|
||||||
|
borderRadius: "6px",
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 0.5rem", color: "#90ee90", fontWeight: "bold" }}>
|
||||||
|
Summary
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: "1.25rem", color: "#ccc", lineHeight: "1.8" }}>
|
||||||
|
<li>
|
||||||
|
Wallet address added:{" "}
|
||||||
|
<span style={{ color: "white", fontFamily: "monospace", fontSize: "0.85rem" }}>
|
||||||
|
{data.walletAddress}
|
||||||
|
</span>
|
||||||
|
{data.walletLabel && ` (${data.walletLabel})`}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Alert rules configured:{" "}
|
||||||
|
<span style={{ color: "white" }}>
|
||||||
|
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Notification channels:{" "}
|
||||||
|
<span style={{ color: "white" }}>
|
||||||
|
{hasNotif ? "Configured" : "Not set up (skipped)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasNotif && (
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleTestChannels}
|
||||||
|
disabled={testLoading}
|
||||||
|
style={{
|
||||||
|
padding: "0.6rem 1.25rem",
|
||||||
|
backgroundColor: testLoading ? "#333" : "#1a4d80",
|
||||||
|
color: "white",
|
||||||
|
border: "1px solid #0066cc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: testLoading ? "not-allowed" : "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testLoading ? "Testing..." : "Test All Channels"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{testResults && (
|
||||||
|
<div style={{ marginTop: "0.75rem" }}>
|
||||||
|
{testResults.error ? (
|
||||||
|
<p style={{ color: "#ff6666" }}>{testResults.error}</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
|
{Object.entries(testResults).map(([channel, result]) => (
|
||||||
|
<li
|
||||||
|
key={channel}
|
||||||
|
style={{
|
||||||
|
color: result.success ? "#90ee90" : "#ff6666",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.success ? "✓" : "✗"} {channel}:{" "}
|
||||||
|
{result.message || (result.success ? "OK" : "Failed")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/addresses")}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 2rem",
|
||||||
|
backgroundColor: "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to Dashboard →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CheckboxRow({ checked, onChange, label, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.6rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
style={{ width: "16px", height: "16px", accentColor: "#0066cc" }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
if (step === 5) return null;
|
||||||
|
|
||||||
|
const canSkip = step === 3 || step === 4;
|
||||||
|
const canBack = step > 1;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
borderTop: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{canBack && (
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#aaa",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
|
{canSkip && (
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#aaa",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1.25rem",
|
||||||
|
backgroundColor: loading ? "#333" : "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Please wait..." : step === 4 ? "Finish" : "Next →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stepContent = {
|
||||||
|
1: <Step1 />,
|
||||||
|
2: <Step2 />,
|
||||||
|
3: <Step3 />,
|
||||||
|
4: <Step4 />,
|
||||||
|
5: <Step5 />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
paddingTop: "3rem",
|
||||||
|
paddingBottom: "3rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: "100%", maxWidth: "540px", padding: "0 1rem" }}>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
color: "#0066cc",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Koin Ping
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ProgressBar />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
backgroundColor: "#3a1a1a",
|
||||||
|
border: "1px solid #cc3333",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ff6666",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skipWarning && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
backgroundColor: "#3a2e00",
|
||||||
|
border: "1px solid #aa7700",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ffcc44",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skipWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#242424",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stepContent[step]}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: "1.25rem",
|
||||||
|
color: "#888",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<a href="/login" style={{ color: "#0066cc" }}>
|
||||||
|
Log in here
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,188 +1,5 @@
|
|||||||
/**
|
import { Navigate } from "react-router-dom";
|
||||||
* 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";
|
|
||||||
|
|
||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
const [email, setEmail] = useState("");
|
return <Navigate to="/onboarding" replace />;
|
||||||
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: "#1a1a1a",
|
|
||||||
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: "#1a1a1a",
|
|
||||||
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: "#1a1a1a",
|
|
||||||
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: "#999" }}>
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
style={{ color: "#0066cc", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
Log in here
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user