Compare commits

..

20 Commits

Author SHA1 Message Date
KS Jannette
6735000050 webhook configs for users, changes to system
Some checks are pending
check / check (push) Waiting to run
2026-03-01 19:06:32 -05:00
KS Jannette
42a64fc043 Add multi-step onboarding wizard to replace bare signup form
Introduces a 5-step guided flow (Create Account → Add Wallet → Alert
Rules → Notifications → Done) that takes new users from zero to fully
configured alerts in one linear session. /signup now redirects to
/onboarding; already-onboarded users who revisit /onboarding are
redirected to /addresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:54:55 -05:00
S Jannette
9935817fa8 Merge pull request #9 from kjannette/setupTelegramonfig
Setup telegramonfig
2026-03-01 09:49:16 -05:00
KS Jannette
8d5716bdb7 Add guide for setup of Telegram
Some checks are pending
check / check (push) Waiting to run
2026-03-01 09:48:52 -05:00
KS Jannette
aafe96d9a1 Finish config 2026-03-01 09:40:54 -05:00
S Jannette
93168209b5 Merge pull request #8 from kjannette/audit1
Audit1
2026-03-01 07:55:57 -05:00
S Jannette
6840af1a47 Merge branch 'master' into audit1 2026-03-01 07:55:49 -05:00
KS Jannette
7c36f3c214 readme change
Some checks are pending
check / check (push) Waiting to run
2026-03-01 07:55:00 -05:00
S Jannette
c707b82f36 Revise README with new project overview
Updated project description and added overview section.
2026-03-01 07:51:57 -05:00
KS Jannette
37ff644aa7 audit: fix issues #1-#9 (mock data, notifier interface, retry/timeout, digest scheduling, real status, RPC retry, dedup, address edit UI)
- #1: remove mock alert events that poisoned production responses
- #2: add Notifier interface + Send implementations for all channels
- #3/#4: notification goroutines now carry a 30s context timeout and retry up to 3x with exponential backoff
- #5: schedule email digest in poller via DIGEST_INTERVAL_HOURS (default 24h)
- #6: SystemStatus queries real checkpoint data; returns starting/active/idle with latestBlock and lag
- #7: Ethereum RPC client retries on network errors, 429, and 5xx with 1s/2s backoff
- #8: alert_events dedup index + ON CONFLICT DO NOTHING to prevent duplicate events on poller restart
- #9: PATCH /v1/addresses/{id} for label editing; frontend address list gains inline edit and remove buttons
2026-03-01 07:49:08 -05:00
S Jannette
c80ed89c0a Merge pull request #7 from kjannette/resend-sdk
add sdk
2026-03-01 04:37:58 -05:00
KS Jannette
6590cbf4be add sdk
Some checks are pending
check / check (push) Waiting to run
2026-03-01 04:31:32 -05:00
KS Jannette
61a8c5fe8b hottie
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:54:06 -05:00
S Jannette
c79136a06e Merge pull request #6 from kjannette/db-updates-to-keep-up-withe-alerts-functionality
notifcation dongif setttings - save to DB
2026-03-01 03:37:04 -05:00
KS Jannette
a5590a438f notifcation dongif setttings - save to DB
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:36:32 -05:00
S Jannette
1c196cae9f Merge pull request #5 from kjannette/notifcations-logic-upgrade
Building out notification update channels
2026-03-01 03:23:31 -05:00
KS Jannette
c494bf5f53 Building out notification update channels
Some checks are pending
check / check (push) Waiting to run
2026-03-01 03:23:08 -05:00
S Jannette
d07dc971fe Merge pull request #4 from kjannette/style2
more style, mostly of w3 compliance
2026-03-01 02:06:25 -05:00
KS Jannette
112c9d0627 more style, mostly of w3 compliance
Some checks are pending
check / check (push) Waiting to run
2026-03-01 02:05:36 -05:00
S Jannette
b1e63a24c7 Merge pull request #3 from kjannette/format-majorly
Format majorly
2026-02-28 20:43:34 -05:00
45 changed files with 2839 additions and 574 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ Prompts/
# Logs # Logs
*.log *.log
.claude

View File

@@ -1,9 +1,10 @@
# Koin Ping # Koin Ping
A lightweight on-chain monitoring and alerting system designed to give users situational awareness over blockchain addresses they care about.
# Overview
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.
Koin Ping is an MIT-licensed blockchain monitoring system by Steven Jannette
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
frontend.
## Getting Started ## Getting Started
@@ -99,19 +100,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

View File

@@ -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>

BIN
backend-go/api Executable file

Binary file not shown.

View File

@@ -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)

View File

@@ -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)
}
} }
} }
} }

View File

@@ -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

View File

@@ -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=

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_notification_configs
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT;

View 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;

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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
@@ -120,19 +112,16 @@ type NormalizedTx struct {
BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle
} }
// 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

View File

@@ -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
} }

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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"))

View 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",
})
}

View File

@@ -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})
}

View File

@@ -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),
})
}

View File

@@ -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`,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View 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"
}
}

View 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
}

View 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"
}
}

View 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
}

View File

@@ -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 {

View 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
}

View File

@@ -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, &notifications.DiscordNotifier{WebhookURL: *cfg.DiscordWebhookURL})
}
if cfg.TelegramBotToken != nil && *cfg.TelegramBotToken != "" &&
cfg.TelegramChatID != nil && *cfg.TelegramChatID != "" {
notifiers = append(notifiers, &notifications.TelegramNotifier{
BotToken: *cfg.TelegramBotToken,
ChatID: *cfg.TelegramChatID,
})
}
if cfg.SlackWebhookURL != nil && *cfg.SlackWebhookURL != "" {
notifiers = append(notifiers, &notifications.SlackNotifier{WebhookURL: *cfg.SlackWebhookURL})
}
if cfg.Email != nil && *cfg.Email != "" {
notifiers = append(notifiers, &notifications.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,25 +260,23 @@ 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,
message,
notifications.AlertMetadata{
TxHash: obs.Hash, TxHash: obs.Hash,
AddressLabel: addressLabel, AddressLabel: addressLabel,
AlertType: string(rule.Type), AlertType: string(rule.Type),
Address: address, 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 {
log.Printf("Notification channel failed for user %s after retries: %v", userID, err)
} else { } else {
log.Printf("Discord notification sent to user %s", userID) log.Printf("Notification sent to user %s via %T", userID, n)
}
} }
} }

View File

@@ -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" }}>
<nav
style={{
marginBottom: "1rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div> <div>
<Link to="/addresses">Addresses</Link> <Navbar />
{" | "}
<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="/history" element={<AlertHistory />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="*" element={<Navigate to="/addresses" />} /> <Route path="*" element={<Navigate to="/addresses" />} />
</Routes> </Routes>
</div> </div>

View File

@@ -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

View File

@@ -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;
} }
} }

View 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: "/history", label: "Alert History" },
];
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.1rem",
color: "#fff",
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
View 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;
}

View File

@@ -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>

View File

@@ -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,26 +102,94 @@ 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" }}>
<div style={{ flex: 1 }}>
{editingId === addr.id ? (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginBottom: "0.25rem" }}>
<input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label (optional)"
style={{ style={{
fontWeight: "bold", background: "#444",
marginBottom: "0.25rem", border: "1px solid #666",
borderRadius: "3px",
color: "#fff",
padding: "0.25rem 0.5rem",
fontSize: "0.9rem",
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(addr.id);
if (e.key === "Escape") handleEditCancel();
}}
autoFocus
/>
<button
onClick={() => handleEditSave(addr.id)}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem" }}
>
Save
</button>
<button
onClick={handleEditCancel}
style={{ cursor: "pointer", padding: "0.25rem 0.6rem", fontSize: "0.85rem", background: "transparent", color: "#aaa", border: "1px solid #555" }}
>
Cancel
</button>
</div>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
<span style={{ fontWeight: "bold" }}>
{addr.label || "Unlabeled"}
</span>
<button
onClick={() => handleEditStart(addr)}
style={{
cursor: "pointer",
background: "transparent",
border: "none",
color: "#6699cc",
fontSize: "0.8rem",
padding: "0",
textDecoration: "underline",
}} }}
> >
{addr.label || "Unlabeled"} Edit
</button>
</div> </div>
)}
<div <div
style={{ style={{
fontFamily: "monospace", fontFamily: "monospace",
fontSize: "0.9rem", fontSize: "1.035rem",
color: "#b3b3b3",
}} }}
> >
{addr.address} {addr.address}
</div> </div>
</div>
<button
onClick={() => handleDelete(addr.id, addr.label)}
style={{
cursor: "pointer",
background: "transparent",
border: "1px solid #884444",
color: "#cc6666",
borderRadius: "3px",
padding: "0.3rem 0.7rem",
fontSize: "0.85rem",
marginLeft: "1rem",
flexShrink: 0,
}}
>
Remove
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -39,7 +39,7 @@ export default function AlertHistory() {
<h1>Recent Alerts</h1> <h1>Recent Alerts</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>

View File

@@ -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);
setNotificationError(null);
}, 6000);
} catch (err) {
setNotificationError(err.message);
} finally {
setTestingChannels(false);
}
}
async function handleSetupEmail() {
try {
setSettingUpEmail(true);
setNotificationError(null);
setNotificationSuccess(null);
await handleSaveNotificationConfig();
const result = await setupEmail();
setNotificationSuccess(
result.message || "Confirmation email sent!",
); );
setTimeout(() => setNotificationSuccess(null), 5000); setTimeout(() => setNotificationSuccess(null), 5000);
} else {
setNotificationError("Test failed. Check your webhook URL.");
}
} catch (err) { } catch (err) {
setNotificationError("Test failed: " + err.message); setNotificationError(err.message);
} finally { } finally {
setTestingWebhook(false); 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,17 +578,146 @@ 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>
<div style={{ marginBottom: "1rem" }}>
<label
style={{ style={{
display: "block", display: notificationEnabled ? "block" : "none",
marginBottom: "0.5rem",
}} }}
> >
{/* Telegram */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Telegram
</h3>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Bot Token</label>
<input
type="text"
value={telegramBotToken}
onChange={(e) =>
setTelegramBotToken(e.target.value)
}
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
style={inputStyle}
/>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>Chat ID</label>
<input
type="text"
value={telegramChatId}
onChange={(e) =>
setTelegramChatId(e.target.value)
}
placeholder="-1001234567890"
style={inputStyle}
/>
</div>
<a
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
style={helpLinkStyle}
>
How to create a Telegram bot & get your Chat ID
</a>
</div>
{/* Email */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Email
</h3>
<div style={{ marginBottom: "0.75rem" }}>
<label style={labelStyle}>Email Address</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
style={inputStyle}
/>
</div>
<div
style={{
fontSize: "0.85rem",
color: "#808080",
marginBottom: "0.75rem",
}}
>
Alert notifications and digests will be sent to
this address
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
}}
>
<button
onClick={handleSetupEmail}
disabled={settingUpEmail || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
settingUpEmail || !email
? "#333"
: "#0066cc",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
settingUpEmail || !email
? "not-allowed"
: "pointer",
}}
>
{settingUpEmail
? "Setting up..."
: "Verify Email"}
</button>
<button
onClick={handleSendDigest}
disabled={sendingDigest || !email}
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
backgroundColor:
sendingDigest || !email
? "#333"
: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
sendingDigest || !email
? "not-allowed"
: "pointer",
}}
>
{sendingDigest
? "Sending..."
: "Send Digest Now"}
</button>
</div>
</div>
{/* Discord */}
<div style={sectionStyle}>
<h3 style={{ marginTop: 0, marginBottom: "1rem" }}>
Discord
</h3>
<div style={{ marginBottom: "0.5rem" }}>
<label style={labelStyle}>
Discord Webhook URL Discord Webhook URL
</label> </label>
<input <input
@@ -510,37 +727,59 @@ export default function Alerts() {
setDiscordWebhookUrl(e.target.value) setDiscordWebhookUrl(e.target.value)
} }
placeholder="https://discord.com/api/webhooks/..." placeholder="https://discord.com/api/webhooks/..."
style={{ style={inputStyle}
width: "100%",
padding: "0.5rem",
fontSize: "1rem",
backgroundColor: "#1a1a1a",
border: "1px solid #444",
borderRadius: "4px",
color: "white",
fontFamily: "monospace",
fontSize: "0.9rem",
}}
/> />
<div </div>
style={{
fontSize: "0.85rem",
color: "#999",
marginTop: "0.5rem",
}}
>
<a <a
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: "#0066cc" }} style={helpLinkStyle}
> >
How to get a Discord webhook URL How to get a Discord webhook URL
</a> </a>
</div> </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> </div>
<div style={{ display: "flex", gap: "0.5rem" }}> <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",

View File

@@ -81,7 +81,7 @@ export default function Login() {
width: "100%", width: "100%",
padding: "0.5rem", padding: "0.5rem",
fontSize: "1rem", fontSize: "1rem",
backgroundColor: "#1a1a1a", backgroundColor: "#242424",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
color: "white", color: "white",
@@ -103,7 +103,7 @@ export default function Login() {
width: "100%", width: "100%",
padding: "0.5rem", padding: "0.5rem",
fontSize: "1rem", fontSize: "1rem",
backgroundColor: "#1a1a1a", backgroundColor: "#242424",
border: "1px solid #444", border: "1px solid #444",
borderRadius: "4px", borderRadius: "4px",
color: "white", color: "white",
@@ -131,7 +131,7 @@ export default function Login() {
</form> </form>
<div style={{ marginTop: "1.5rem", textAlign: "center" }}> <div style={{ marginTop: "1.5rem", textAlign: "center" }}>
<p style={{ color: "#999" }}> <p style={{ color: "#b3b3b3" }}>
Don't have an account?{" "} Don't have an account?{" "}
<Link <Link
to="/signup" to="/signup"

View 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>
);
}

View File

@@ -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>
);
} }